Skip to content

A pasted node or a channel row can crash a document page for every reader #436

Description

@HMarzban

Summary

The webapp has no React error boundary, so one render error unmounts the whole document page. Two inputs that other people control reach such an error. The page stays broken for every reader until the bad data is removed.

  1. A paste. The legacy mediaUploadPlaceholder node still parses pasted HTML. Any editor can paste it, including a signed-out visitor on a public document that is not Read-only. A filetype value such as constructor makes the icon lookup return Object, and React throws. The change syncs before the author's tab crashes, so every later load crashes too.
  2. A channel row. syncHeadingWidgetUnread puts each channel id into document.querySelector without escaping. A channel id that holds "] throws a SyntaxError inside a layout effect. Channels load for every reader of the document, signed in or not. Any signed-in user can insert a channel row into a document's workspace, because join_workspace makes them a member when they open it.
  • Severity: Medium (availability of a document for all readers)
  • Area: webapp editor node, webapp heading unread widget
  • Source: security review of 2026-10-06, finding M2. Both paths confirmed in code.

Production check (2026-10-06, read-only)

  • No channel id in production fails ^[A-Za-z0-9_-]+$ today.
  • channels_member_insert still accepts any id. Migration 20261006130000 did not change that policy.

Where

  • Paste path:
    • apps/webapp/src/components/TipTap/nodes/MediaUploadPlaceholder.tsx:64-66 (parseHTML).
    • apps/webapp/src/components/TipTap/nodes/MediaUploadPlaceholderBody.tsx:19 (icons[fileType] || icons.image in FileTypeIcon).
    • The server mirror of this node has no parseHTML: apps/hocuspocus.server/src/lib/migration-extensions.ts:29-45.
  • Selector path:
    • apps/webapp/src/services/headingWidgetUnread.ts:41 ([data-toc-id="${channelId}"]).
    • Called from the useLayoutEffect in apps/webapp/src/hooks/useUnreadSync.ts:17-20.
    • Channels load for every reader through apps/webapp/src/api/channels/getChannels.ts:43-53.
  • Channel insert: policy channels_member_insert (created_by = auth.uid() AND internal.is_workspace_member(workspace_id)) in packages/supabase/scripts/13-RLS.sql:125-130. channels.id is varchar(36) with no format check (packages/supabase/scripts/04-channels.sql:5).

Fix plan

  1. MediaUploadPlaceholder.tsx: delete the parseHTML() method. Nothing inserts this node any more, so it must never come from a paste. Stored nodes still load from Yjs, which does not use parseHTML. The server mirror already has none.
  2. MediaUploadPlaceholderBody.tsx, FileTypeIcon: use Object.hasOwn(icons, fileType) ? icons[fileType] : icons.image. This covers a node that arrives through a raw Yjs client, which skips the parser.
  3. headingWidgetUnread.ts:41: wrap the id in CSS.escape(channelId). TocTickRail.tsx:281 and messageJumpTiming.ts:36 already use this form.

Layer: the fix stays in the editor node and in the unread service. Do not change useUnreadSync, getChannels, or any SQL.

Out of scope

  • A format check on channels.id. CSS.escape closes this crash. After Scope heading chat channels to their own document #402, a client can no longer choose channels.id at all.
  • An error boundary around the editor and the TOC. It is a separate resilience change, and its fallback needs design-system work. File it on its own if wanted.
  • Other unescaped selectors (TocDesktop.tsx:68, toc/dnd/utils.ts:8, toc/hooks/useTocDrag.ts:206, toc/hooks/tocActions.ts:94, TipTap.tsx:81). They run in event handlers or timers, not in render. A bad id throws once there and does not unmount the page.

Acceptance criteria

  • Pasting HTML that holds a data-type="media-upload-placeholder" element inserts no placeholder node, and the editor keeps working.
  • A stored placeholder node with an unknown fileType renders the image icon.
  • With a channel row whose id holds a double quote in the document's workspace, the document page loads for signed-in and signed-out readers.
  • In that state, the heading chat buttons of other headings still show their unread counts.
  • Upload placeholders during an upload, and stored legacy placeholders, still render, and Cancel still removes a stored one.

Verify

  • bun run --filter @docs.plus/webapp typecheck and bun run lint.
  • Add one case to apps/webapp/cypress/e2e/editor/copy-paste/standard-paste.cy.js. Paste placeholder HTML with an unknown filetype, then assert that no .media-upload-placeholder exists and typing still works. From apps/webapp, with the dev stack running: bun run cypress:run --spec cypress/e2e/editor/copy-paste/standard-paste.cy.js.
  • Manual, selector path: in local Supabase, insert a channels row for the document's workspace whose id holds a double quote. Load the document signed in and signed out. The page renders, and the browser console shows no SyntaxError from headingWidgetUnread.ts.

Related


Generated by Claude Code

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-][Security] One paste or one channel row crashes a document page for every reader[/-] [+]A pasted node or a channel row can crash a document page for every reader[/+] on Oct 6, 2026
  3. added
    EditorTiptap & Prosemirror
    ChatRelated to chat features
    SecuritySecurity, access control, and data exposure
    on Oct 6, 2026
  4. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Fixed and live.

    • Commits: cda1150e4
    • Deployed in bbdaae66c (production run 37976785981, green).
    • Pasted HTML never creates an upload placeholder, and the heading unread sync escapes each channel id.
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

    ChatRelated to chat featuresEditorTiptap & ProsemirrorSecuritySecurity, access control, and data exposurebugSomething isn't working

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions