Repository navigation
Make a Private document's chat readable and writable only by its owner #396
Copy link
Copy link
Closed
Labels
ChatRelated to chat featuresRelated to chat featuresHigh PrioritySecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Milestone
Description
Summary
A Private document hides its content and its Pad title, but not its chat. Supabase does not know which documents are Private, so every chat policy treats every channel as public. The anon key reads the chat, any signed-in account can post into it, and former members keep getting notifications. join_workspace also lets any signed-in user read the member list.
- Severity: High
- Area: Supabase RLS, storage policies, notification triggers, hocuspocus Access mutation
- Source: security review of 2026-10-06, finding H1
Production check (2026-10-06, read-only)
- All 1022 channels in production have
type = 'PUBLIC'. anonholds SELECT onpublic.messages. The anon policymessages_public_anon_selectallows any row whose channel is PUBLIC.messages_visible_select(authenticated) andmessages_self_insertboth rely oninternal.can_read_channel, which returns true for every PUBLIC channel.- 373 of 845 live workspaces are visible to anon through
workspaces_public_anon_select. - The Private flag lives in the hocuspocus Prisma database, not in Supabase. So no policy can see it today.
Where
internal.can_read_channel:packages/supabase/scripts/10-0-func-helpers.sql:143-162.- Authenticated chat policies in
packages/supabase/scripts/13-RLS.sql:channels_visible_select:121-123,channel_members_join_insert:154-165,messages_visible_selectandmessages_self_insert:191-200,pinned_messages_select:214-216,channel_message_counts_select:225-227. - Anon policies:
packages/supabase/scripts/13-RLS.sql:278-338. They expose every message, media path and live message (Realtime).channels.last_message_previewalso carries message text. - Chat media read policies:
packages/supabase/scripts/12-buckets.sql:146-178. chatroom_read_topic_access:packages/supabase/scripts/10-functions.sql:1336-1351.join_workspace(no access check):packages/supabase/scripts/10-functions.sql:841-927.get_document_members:packages/supabase/scripts/10-8-func-workspace_members.sql:213-249.- After a Private flip, past members still get bell rows, push (with
message_previewon the lock screen) and email. Notification triggers pick receivers fromchannel_membersor the parent author:packages/supabase/scripts/10-func-notifications.sql:9(mention),:96(reply),:183(everyone),:265(regular),:357(reaction). The unread auto-enrol is at:464. - The Access mutation writes only Prisma:
updateDocumentinapps/hocuspocus.server/src/api/services/documents.service.ts:600-641.updateDocumentFlagsinapps/hocuspocus.server/src/api/services/adminStats.service.ts:243-288.
- The MCP server already refuses chat on a Private document (
apps/hocuspocus.server/API.md:1066). The webapp path and the MCP path disagree.
Root cause
isPrivate and ownerId live only in Prisma. They gate the REST slug read and the WebSocket room. Supabase never receives them.
Fix plan
One design covers every path. Reads stay owner-only for a Private document, which matches the WebSocket rule.
- Mirror table. Add
public.document_access (document_id varchar(36) primary key, is_private boolean not null, owner_id uuid).- Enable RLS with no policy, and revoke all from
anonandauthenticated. Only the service role writes it. - Do not add columns to
public.workspaces.join_workspacecreates that row lazily and is its only writer, so a flip can happen before the row exists. - A missing row means public.
document_idis the documentId verbatim, the same value aschannels.workspace_id.
- Enable RLS with no policy, and revoke all from
- One helper. Add
internal.can_open_document(p_document_id varchar, p_user_id uuid) returns boolean,security definer,stable. It returns true when no row exists, the row is not Private, orowner_id = p_user_id.- Grant EXECUTE to
anonandauthenticated. Also grant USAGE on schemainternaltoanon. Todayanonlacks it (13-RLS.sql:17-19).
- Grant EXECUTE to
- Gate reads and writes on the helper. AND the helper into each existing check, so channel membership alone never opens a Private document. Join
channelsto getworkspace_idwhere a policy only haschannel_id.internal.can_read_channel, withauth.uid(). This also coversmessages_self_insert, pinned, counts,get_user_bookmarksand the INVOKER chat RPCs.channels_visible_select, withauth.uid().- The anon policies on
channels,messages,channel_message_counts,pinned_messagesandworkspaces, with a null user. channel_members_join_insert, the three chat media read policies, andchatroom_read_topic_access.join_workspaceandget_document_members: refuse a non-owner of a Private document.- Any other
security definerfunction that returns message text without callinginternal.can_read_channel. List them with the query in Verify.
- Gate notifications. In the five triggers above, skip a receiver when
internal.can_open_document(c.workspace_id, <receiver id>)is false. Apply the same check to the auto-enrol inincrement_unread_count_on_new_message. Push and email are queued from these rows, so they follow. - Write the mirror from hocuspocus. Add one helper in
apps/hocuspocus.server/src/lib/besideaccessRealtime.ts. It upserts the row throughgetServiceRoleClient(), aslib/padTitleChange.tsdoes.- Call it from
updateDocumentandupdateDocumentFlagswhenever the request carriesisPrivate, even when the value did not change. A retry then repairs a failed write. - Write the stored values (
isPrivateandownerIdfrom the row Prisma returns), not the request value.updateDocumentignores anisPrivatechange on an open document. - Await it before the response. If it fails, return a 5xx so the owner can retry. Do not skip the wait: a lost write leaves the chat public.
- Call it from
- Backfill. Add a one-off Bun script,
apps/hocuspocus.server/scripts/backfill-document-access.ts. It reads everydocumentMetadatarow withisPrivate = trueand upserts its mirror row.
Repo rules that apply:
- Put the SQL in one new migration, and mirror it in
packages/supabase/scripts/(10-0-func-helpers.sql,13-RLS.sql,12-buckets.sql,10-functions.sql,10-8-func-workspace_members.sql,10-func-notifications.sql). - Seed order matters. Create the table in a new script numbered before
10-0-func-helpers.sql, for example03-2-document_access.sql. - The notification triggers live in
scripts/only, so recreate them inside the migration too (packages/supabase/CLAUDE.md). Keep every trigger functionsecurity definer. - Regenerate the seed with
bun run --filter @docs.plus/supabase_back seed. Never hand-editpackages/supabase/seed.sql. - Run
bun run --filter @docs.plus/supabase_back typesand commit the regeneratedapps/webapp/src/types/supabase.ts.
Out of scope
- Signed media URLs minted before the fix stay valid until they expire. Say so in the PR.
- Push, email and digest entries for notifications created before a flip still send once.
- The heading chat channel key (Scope heading chat channels to their own document #402). A duplicate of a Private document can open the source's chat through that bug.
- Editor media after Private (Stop serving editor media after a document moves to Trash #408) and deleted message text (Clear the text of a chat message when it is deleted #435).
Acceptance criteria
- With the anon key, a read of
public.messagesorpublic.channelsfor a Private document returns no rows. - A signed-in non-owner gets
42501on an insert into a Private document's chat. -
join_workspaceandget_document_membersrefuse a non-owner of a Private document. - A new message in a Private document creates no notification, push or email for a non-owner.
- The owner's chat still works: read, send, realtime, media, unread counts and notifications.
- Public documents behave as before, including anon read of their chat.
- After the backfill, every Private document in Prisma has a mirror row with
is_private = true. - Making a document public again restores chat for others.
Verify
- Apply the migration locally:
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql. - As user A, make a document Private in the local webapp. Confirm its mirror row exists.
- Check each criterion in
psql, insidebegin; ... rollback;:- As anon:
set local role anon; - As user B:
set local role authenticated;thenselect set_config('request.jwt.claims', '{"sub":"<user B id>","role":"authenticated"}', true);
- As anon:
- List DEFINER readers that skip the gate:
select p.oid::regprocedure from pg_proc p where p.prosecdef and p.pronamespace = 'public'::regnamespace and p.prosrc ilike '%messages%' and p.prosrc not ilike '%can_read_channel%'; - Test the mirror write:
cd apps/hocuspocus.server && bun test tests/integration/documents.test.ts. Add a case for a flip that writes the row, if the suite has a Supabase seam. - The chatroom Cypress suite intercepts Supabase HTTP and cannot test policies. Check the owner path by hand in the local webapp.
Related
- Scope heading chat channels to their own document #402 heading chat binds to a global channel id
- Stop serving editor media after a document moves to Trash #408 editor media stays readable after Private
- Clear the text of a chat message when it is deleted #435 deleted chat text stays readable
- Stop bookmarks and replies from copying a message the user cannot read #410
get_user_bookmarksnow filters oninternal.can_read_channel, so this gate covers it - Let non-members read only chat media that a live message uses #432 rewrites the two non-member chat media read policies. The second change to land must keep both conditions.
- Stop clients from writing server-owned columns on chat messages #400 changes the
messagesgrants and the policy WITH CHECK clauses. This issue changesinternal.can_read_channel, so the two migrations can land in either order.
Generated by Claude Code
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
ChatRelated to chat featuresRelated to chat featuresHigh PrioritySecuritySecurity, access control, and data exposureSecurity, access control, and data exposurebugSomething isn't workingSomething isn't working
Activity
[-][Security] Private document chat is readable without an account through the Supabase Data API[/-][+]Make a Private document's chat readable and writable only by its owner[/+]fix(supabase): close a Private document's chat to everyone but its owner
fix(hocuspocus): write the Private mirror first when Private turns on
fix(supabase): erase the Private mirror row on purge and correct gran…
docs(agents): file inbox facts and record the Private chat fix
HMarzban commentedon Oct 9, 2026
Reopened: the board automation closed this issue before the push. The code is
c2c234e50,717f47795,43b4aece9and528a8c2c3. It stays open until: (1) migration20261008130000_private_document_chat_gate.sqlis applied on prod; (2) the backend is deployed after it, or every Private flip answers 503; (3)scripts/backfill-document-access.tsruns, dry run first, then--apply; (4) the PrismaisPrivatecount matches thedocument_accesscount.HMarzban commentedon Oct 9, 2026
Deployed 2026-10-09 (run 37906965365, deploy job finished 09:28:41Z). Migration
20261008130000_private_document_chat_gate.sqlis applied, the backend is deployed, and the backfill wrote 3 of 3 rows. Checked on prod: 3 Private documents in Prisma, 3document_accessrows;internal.can_open_documentrefuses a visitor and admits the owner on each.