Repository navigation
Scope heading chat channels to their own document #402
Description
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.
duplicateDocumentcopies the Yjs bytes, so the copy keeps everytoc-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.
- Foreign row. A user creates a channel row whose id is another document's heading
- 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. authenticatedholds INSERT on everychannelscolumn, includingid,type,workspace_idandmember_count.workspaceskeys:PRIMARY KEY (id),UNIQUE (slug). Channels have a globalPRIMARY 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 onchannels. INSERT is unchanged.
Where
syncChannelreads by id alone and reuses any row:apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:24-71.upsertChannelusesonConflict: 'id', ignoreDuplicates: true, so a taken id silently writes nothing:apps/webapp/src/api/channels/newChannel.ts:6-12.get_channel_aggregate_datalooks upc.idonly:packages/supabase/scripts/10-functions.sql:84-85.- Insert grant and policy:
packages/supabase/scripts/13-RLS.sql:32and:125-130. channels.slugis 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_adminmakes 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 onechatRoom.headingIdand uses it as the channel id (apps/webapp/src/stores/chat/chatroom.ts:12,49-51). - Duplicate copies Yjs bytes:
duplicateDocumentinapps/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 aschannels.id:apps/hocuspocus.server/src/modules/mcp/infra/chatStore.ts:32-113andapps/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.
- Schema (migration +
scripts/04-channels.sql).- Add
heading_id varchar(36)topublic.channels. Backfillheading_id = idfor every row, then set itnot null. - Add
unique (workspace_id, heading_id). - Replace the global
unique (slug)withunique (workspace_id, slug). A client still choosesslug, 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_idin 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.
- Add
- Insert grant (migration +
scripts/13-RLS.sql:32). Replace the table-wide INSERT withINSERT (workspace_id, heading_id, created_by, name, slug). A client can no longer chooseid,type,member_countordeleted_at. New rows take the defaultid. - Webapp: one resolve point. The chat store keeps
headingIdfor UI and TOC, and adds the resolvedchannelIdfor database reads, writes and realtime topics.syncChannelfinds the row by(workspaceId, headingId). If none exists, it upserts withonConflict: 'workspace_id,heading_id', then reads again.- List the uses of
chatRoom.headingIdwithgrep -rnE "chatRoom\??\.headingId" apps/webapp/src(27 hits today). Switch each one that reads or writes the database tochatRoom.channelId. - TOC unread badges map rows by
heading_id:apps/webapp/src/services/headingWidgetUnread.ts:33-41andapps/webapp/src/api/channels/getChannels.ts. - The chatroom Cypress fixtures own all Supabase HTTP. Update any fixture that stubs the channel read by
id.
- 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 whoseworkspace_idis not the open document, and open itsheading_id:apps/webapp/src/hooks/useCheckUrlAndOpenHeadingChat.ts:14-24apps/webapp/src/hooks/useNotificationClickBridge.ts:46-52openChatAtMessageinapps/webapp/src/components/PanelFeedItem.tsx:145-150
- MCP chat store.
hasRoom,readThreadandpostMessagereceive a heading id asroomId. Resolve it by(workspace_id, heading_id)to the channelidbefore the message query. - Run
bun run --filter @docs.plus/supabase_back seed, thenbun run --filter @docs.plus/supabase_back types. The new column changes the generated types, so commitapps/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
- Re-minting
toc-ids induplicateDocument. The per-document key already keeps a copy's chat separate. - Changing
get_channel_aggregate_data. The client passes it the resolved channel id. - A format check on
channels.id. Step 2 takes the choice ofidaway from clients, and A pasted node or a channel row can crash a document page for every reader #436 escapes the id in the selector.
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
idis refused with42501. - 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_threadandpost_chat_messagestill work on new and old rooms.
Verify
- Apply locally:
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql. - In
psql, as user B (set local role authenticated;plusrequest.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. - In the local webapp, duplicate a document whose headings have chat. Open each heading chat in the copy.
- Open an old push link, an email link and a copied message link.
- Run
cd apps/webapp && bun run test, and the chatroom Cypress suite withbash scripts/test-chatroom.sh.
Related
- Make a Private document's chat readable and writable only by its owner #396 Private document chat. Do this issue with it or right after it (see the order of work in [Tracking] Security review 2026-10-06 fixes #416).
- A pasted node or a channel row can crash a document page for every reader #436 page crash from a channel row. It escapes the channel id in the heading selector. Neither issue adds a format check on
channels.id. - Stop channel members from muting or renaming a channel for everyone #401 revoked client UPDATE on
channels.
Generated by Claude Code
Activity
[-][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[/+]fix(chat): key heading chats by document and narrow the channel inser…
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
fix(webapp): show the composer skeleton while a heading chat waits fo…
HMarzban commentedon Oct 9, 2026
Now fully live. Migrations
20261009115900(column, keys, fill trigger) and20261009130100(narrowed INSERT grant) are applied on production and verified read-only:heading_idis NOT NULL with no null rows, both per-document keys exist, and a client INSERT namingchannels.idis refused. Code deployed inbbdaae66c.