Repository navigation
Stop clients from writing server-owned columns on chat messages #400
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
authenticated holds INSERT and UPDATE on every column of public.messages. RLS checks only user_id and channel read access. So any signed-in user can set columns the server should own: seq, type, created_at, reactions, readed_at and, on UPDATE, channel_id.
- Severity: High. Impact:
- Lost messages, platform-wide. A user inserts rows that take the next values of
messages_seq_seq. Later sends fail with23505, and the client reports success. Other users' messages disappear with no error. - Forged notices. A
type = 'notification'row with atitle_changedpayload shows in History as the newest rename, under anyuser_namethe writer chooses. A far-futurecreated_atkeeps it on top. - Broken catch-up. A very large
seqpasses thefetch_messages_sincecursor, so clients can miss live messages after a reconnect. - Channel move. An UPDATE moves the caller's own message into a channel they cannot read, past the INSERT check.
- Forged reactions. A user can write reactions in other users' names onto their own message. The direct UPDATE skips
add_reactionandremove_reaction.
- Lost messages, platform-wide. A user inserts rows that take the next values of
- Area: Supabase grants and RLS on
public.messages - Source: security review of 2026-10-06, finding H4
Production check (2026-10-06, read-only)
information_schema.column_privilegesshows INSERT and UPDATE forauthenticatedon all 22 columns ofmessages, includingseq,type,created_at,channel_idandreactions.messages_self_insert:user_id = auth.uid() AND internal.can_read_channel(channel_id).messages_self_update:user_id = auth.uid()only. It does not re-check the channel.
Where
- Table-wide grant:
packages/supabase/scripts/13-RLS.sql:34, mirrored bypackages/supabase/migrations/20260716130500_grant_authenticated_chat_table_privileges.sql:11. - Policies:
messages_self_insertandmessages_self_updateinpackages/supabase/scripts/13-RLS.sql:195-205. seqis only a column default (packages/supabase/scripts/05-0-message.sql:11) with a unique constraint (:31). No trigger overrides it.isDuplicateKeyErrortreats any23505as a successful send:apps/webapp/src/components/chatroom/utils/postgresErrors.ts:7-13, used atapps/webapp/src/components/chatroom/utils/sendChatMessage.ts:76,115,127.- History trusts
type = 'notification'rows and paintsmetadata.user_name:apps/webapp/src/components/pages/history/getLatestPadTitleChange.ts:16-37. - Catch-up cursor
m.seq > p_since_seqinfetch_messages_since:packages/supabase/scripts/10-functions.sql:1251. - Every client write to
messages:- INSERT:
persistChatMessage(apps/webapp/src/components/chatroom/utils/sendChatMessage.ts:40-52,74),sendMessage(apps/webapp/src/api/messages/sendMessage.ts:20-35),sendCommentMessage(apps/webapp/src/api/messages/sendCommentMessage.ts:20-33). - UPDATE:
updateMessage(apps/webapp/src/api/messages/updateMessage.ts:11-37),deleteMessage(apps/webapp/src/api/messages/deleteMessage.ts:3-10). - The MCP
postMessage(apps/hocuspocus.server/src/modules/mcp/infra/chatStore.ts:98-113) uses the service role, so this change does not affect it.
- INSERT:
Fix plan
Column grants close seq, created_at, reactions, readed_at and the channel move. One policy clause closes type = 'notification'. No new trigger is needed.
- Column grants. In
scripts/13-RLS.sql, replace line 34 withGRANT SELECT ON public.messages TO authenticatedplus two column grants:- INSERT
(id, channel_id, user_id, content, html, medias, type, metadata, reply_to_message_id). These are the columns the three INSERT writers send.client_idis not written (seeapps/webapp/src/components/chatroom/components/MessageCard/MessageCardContext.tsx:128-129). - UPDATE
(content, html, medias, type, deleted_at). These are the columnsupdateMessageanddeleteMessagesend.edited_atis set by theset_message_edited_attrigger, which needs no grant. - In the migration, first
revoke insert, update on public.messages from authenticated. A table-wide grant hides any column-level revoke.
- INSERT
- Policy clause. Add
AND type IS DISTINCT FROM 'notification'to the WITH CHECK ofmessages_self_insertandmessages_self_update.notify_user_join_workspaceandnotify_document_title_changestill post notices. They aresecurity definerand run as the table owner, so RLS does not apply to them.- Do not use
auth.role()here. The join notice is inserted while the JWT role isauthenticated.
- Production pre-check, read-only. Before you apply the migration, count rows with a client-forged
seq:select count(*) from public.messages where seq > (select last_value from public.messages_seq_seq);. If the count is not 0, stop and report it on this issue.
Repo rules that apply:
- Ship the SQL as one new migration and mirror it in
packages/supabase/scripts/13-RLS.sql. - 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 types. Grants and policies do not change the generated types, so expect no diff.
Out of scope
- Narrowing
isDuplicateKeyErrortomessages_pkey. Once clients cannot writeseq, a client cannot cause aseqconflict. The helper's comment already says it means the primary key. - Locking the keys a client may put in
metadataon INSERT.
Acceptance criteria
- An insert that names
seqorcreated_atis refused with42501. - An insert or update with
type = 'notification'from a signed-in client is refused. - An update that names
channel_id,user_idorreactionsis refused with42501. - Send, edit, delete, reply, comment and media messages still work in the webapp.
- Reactions still work through
add_reactionandremove_reaction. - The "joined" notice and the
title_changednotice still post.
Verify
- Apply locally:
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql. - In
psql, insidebegin; ... rollback;, act as a signed-in user:
set local role authenticated;thenselect set_config('request.jwt.claims', '{"sub":"<user id>","role":"authenticated"}', true);
Run one insert or update per refusal above, and expect the error code. - In the local webapp, send, edit, delete, reply, react, comment and attach media. Rename a Pad title and open History.
- The chatroom Cypress suite (
bash scripts/test-chatroom.sh) intercepts Supabase HTTP. It checks the client payloads, not the grants.
Related
- Stop channel members from muting or renaming a channel for everyone #401 and Stop signed-in users from squatting a workspace slug to block chat #409 closed the client UPDATE paths on
channelsandworkspacesin migration20261006130000_close_client_write_and_grant_gaps.sql. - Make a Private document's chat readable and writable only by its owner #396 changes
messages_self_insertthroughinternal.can_read_channel. Land the two migrations in either order; they touch different clauses.
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] Clients can write server-owned columns on chat messages (seq, type, created_at, channel_id)[/-][+]Stop clients from writing server-owned columns on chat messages[/+]fix(supabase): limit client writes on chat messages to their own columns
fix(supabase): close a Private document's chat to everyone but its owner
fix(supabase): erase the Private mirror row on purge and correct gran…
HMarzban commentedon Oct 9, 2026
Reopened: the board automation closed this issue before the push. The code is
e7596402c(migration20261008120000_lock_message_write_columns.sql). The read-only prod prechecks on 2026-10-08 were all 0: noseqahead of the sequence, no futurecreated_at, and no notice with no type. The prod-only columns are nullable or defaulted, and no app code writes them. It stays open until the migration is applied on prod.HMarzban commentedon Oct 9, 2026
Deployed 2026-10-09 (run 37906965365, deploy job finished 09:28:41Z). Migration
20261008120000_lock_message_write_columns.sqlis applied on prod. Checked read-only:authenticatedhas no table-wide INSERT onmessages; it may insertcontentbut notseq.