Skip to content

Scope heading chat channels to their own document #402

Closed

Description

@HMarzban

Summary

A heading's chat channel id is the heading's toc-id, and channels.id is one global primary key. The webapp opens a channel by that id alone, with no workspace_id check. So another document's channel row can answer for this document's heading. A signed-in user can insert such a row, and every duplicate already shares its source's rows.

  • Severity: Medium. Impact:
    • Foreign row. A user creates a channel row whose id is another document's heading toc-id, under their own document. It works only for a heading whose chat nobody has opened yet. That heading's chat then opens the foreign row. Messages posted there belong to the creator's document, and the creator is the row's ADMIN member.
    • Duplicate. duplicateDocument copies the Yjs bytes, so the copy keeps every toc-id. Heading chat in the copy opens the source's channels. A public copy of a Private document shows the source's chat. Only the owner can duplicate, so this leak needs an owner action.
    • After Make a Private document's chat readable and writable only by its owner #396. Once Private chat is gated, a foreign row in a public document would bypass that gate.
  • Area: Supabase channels, webapp chatroom, MCP chat store
  • Source: security review of 2026-10-06, finding M4

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

  • Policy channels_member_insert: WITH CHECK (created_by = auth.uid() AND internal.is_workspace_member(workspace_id)). Any id is accepted.
  • authenticated holds INSERT on every channels column, including id, type, workspace_id and member_count.
  • workspaces keys: PRIMARY KEY (id), UNIQUE (slug). Channels have a global PRIMARY KEY (id).
  • Migration 20261006130000_close_client_write_and_grant_gaps.sql (Stop channel members from muting or renaming a channel for everyone #401) later revoked UPDATE on channels. INSERT is unchanged.

Where

  • syncChannel reads by id alone and reuses any row: apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:24-71.
  • upsertChannel uses onConflict: 'id', ignoreDuplicates: true, so a taken id silently writes nothing: apps/webapp/src/api/channels/newChannel.ts:6-12.
  • get_channel_aggregate_data looks up c.id only: packages/supabase/scripts/10-functions.sql:84-85.
  • Insert grant and policy: packages/supabase/scripts/13-RLS.sql:32 and :125-130.
  • channels.slug is globally unique (packages/supabase/scripts/04-channels.sql:9). The webapp sets it to 'c' + slugify(channelId) (useChannelMetadata.ts:45-51).
  • add_channel_creator_as_admin makes the creator an ADMIN member: packages/supabase/scripts/10-2-func-channels.sql:16.
  • Channel id = heading toc-id: apps/webapp/src/services/openHeadingChatroom.ts:77. The chat store keeps one chatRoom.headingId and uses it as the channel id (apps/webapp/src/stores/chat/chatroom.ts:12,49-51).
  • Duplicate copies Yjs bytes: duplicateDocument in apps/hocuspocus.server/src/api/services/documents.service.ts:813-929.
  • The MCP chat store already filters on workspace_id, but it still treats a heading id as channels.id: apps/hocuspocus.server/src/modules/mcp/infra/chatStore.ts:32-113 and apps/hocuspocus.server/CLAUDE.md:140.

Root cause

The channel key is a client-chosen global id, not a per-document key. The first insert of an id owns it.

Fix plan

Key heading channels by (workspace_id, heading_id). Existing rows keep their id, so messages, media paths ({userId}/{channelId}/…), realtime topics and old links do not change.

  1. Schema (migration + scripts/04-channels.sql).
    • Add heading_id varchar(36) to public.channels. Backfill heading_id = id for every row, then set it not null.
    • Add unique (workspace_id, heading_id).
    • Replace the global unique (slug) with unique (workspace_id, slug). A client still chooses slug, so a global key would let a user take another document's slug and block its channel. The client keeps sending 'c' + slugify(headingId).
    • Two server-side writers insert channels. Set heading_id in both, and recreate both in the migration:
      • notify_user_join_workspace (scripts/10-8-func-workspace_members.sql:28-45): heading_id = new.workspace_id.
      • The DIRECT channel insert in scripts/10-functions.sql:511: heading_id = new_channel_id.
  2. Insert grant (migration + scripts/13-RLS.sql:32). Replace the table-wide INSERT with INSERT (workspace_id, heading_id, created_by, name, slug). A client can no longer choose id, type, member_count or deleted_at. New rows take the default id.
  3. Webapp: one resolve point. The chat store keeps headingId for UI and TOC, and adds the resolved channelId for database reads, writes and realtime topics.
    • syncChannel finds the row by (workspaceId, headingId). If none exists, it upserts with onConflict: 'workspace_id,heading_id', then reads again.
    • List the uses of chatRoom.headingId with grep -rnE "chatRoom\??\.headingId" apps/webapp/src (27 hits today). Switch each one that reads or writes the database to chatRoom.channelId.
    • TOC unread badges map rows by heading_id: apps/webapp/src/services/headingWidgetUnread.ts:33-41 and apps/webapp/src/api/channels/getChannels.ts.
    • The chatroom Cypress fixtures own all Supabase HTTP. Update any fixture that stubs the channel read by id.
  4. Webapp: links. Push, email, digest, report and copy-link URLs carry a channel id. Keep those builders as they are. The link entry points read the row by id, refuse a row whose workspace_id is not the open document, and open its heading_id:
    • apps/webapp/src/hooks/useCheckUrlAndOpenHeadingChat.ts:14-24
    • apps/webapp/src/hooks/useNotificationClickBridge.ts:46-52
    • openChatAtMessage in apps/webapp/src/components/PanelFeedItem.tsx:145-150
  5. MCP chat store. hasRoom, readThread and postMessage receive a heading id as roomId. Resolve it by (workspace_id, heading_id) to the channel id before the message query.
  6. Run bun run --filter @docs.plus/supabase_back seed, then bun run --filter @docs.plus/supabase_back types. The new column changes the generated types, so commit apps/webapp/src/types/supabase.ts.

Rejected: keep client ids and only check workspace_id on read. The foreign row then blocks the real document from creating its channel. Every heading chat in an old duplicate would also stop working.

Out of scope

Acceptance criteria

  • A channel row that document B created with a heading id from document A is never opened by document A's heading chat.
  • A client insert that names id is refused with 42501.
  • Heading chat in a duplicate, old or new, starts empty and does not show the source's messages.
  • Existing heading chats keep their history, unread badges and media.
  • Old links (?chatroom=, c_id, push, email, digest) still open the right heading chat.
  • A link to a channel of another document does not open it.
  • MCP list_chat_rooms, read_chat_thread and post_chat_message still work on new and old rooms.

Verify

  1. Apply locally: docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql.
  2. In psql, as user B (set local role authenticated; plus request.jwt.claims), insert a channel under B's document with a heading id from document A. Then open that heading's chat in document A in the local webapp. Expect a new, empty row under document A.
  3. In the local webapp, duplicate a document whose headings have chat. Open each heading chat in the copy.
  4. Open an old push link, an email link and a copied message link.
  5. Run cd apps/webapp && bun run test, and the chatroom Cypress suite with bash scripts/test-chatroom.sh.

Related


Generated by Claude Code

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-][Security] Heading chat binds to a global channel id, so it can be hijacked, and a duplicate shares the source's chat[/-] [+]Scope heading chat channels to their own document[/+] on Oct 6, 2026
  3. added
    ChatRelated to chat features
    SecuritySecurity, access control, and data exposure
    on Oct 6, 2026
  4. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Now fully live. Migrations 20261009115900 (column, keys, fill trigger) and 20261009130100 (narrowed INSERT grant) are applied on production and verified read-only: heading_id is NOT NULL with no null rows, both per-document keys exist, and a client INSERT naming channels.id is refused. Code deployed in bbdaae66c.

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 featuresSecuritySecurity, access control, and data exposurebugSomething isn't working

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions