Summary
Deleting a chat message only sets deleted_at. The text stays in content and html. The signed-in read policy does not filter deleted_at, and every chat channel is PUBLIC. So any signed-in account can read the text of every deleted message through the Data API.
- Severity: Medium (privacy: "delete" does not delete)
- Area: Supabase
messages triggers
- Source: security review of 2026-10-06, finding M7
Production check (2026-10-06, read-only)
- 6 soft-deleted messages still hold text.
messages_visible_select is USING (internal.can_read_channel(channel_id)) with no deleted_at filter.
Where
- Delete sets only
deleted_at: apps/webapp/src/api/messages/deleteMessage.ts:3-10.
handle_message_soft_delete and its trigger message_soft_delete: packages/supabase/scripts/10-3-func-message.sql:21-107 and :116-120. The trigger is AFTER UPDATE OF deleted_at, so assigning NEW.content inside it has no effect.
update_message_previews (AFTER UPDATE OF content, medias, type): packages/supabase/scripts/10-3-func-message.sql:161-169. It rewrites reply previews and the channel preview when content is in the SET list.
- Policy:
messages_visible_select in packages/supabase/scripts/13-RLS.sql:191-193.
- Nothing purges soft-deleted rows later.
- The UI hides deleted rows because the read RPCs filter them. Anon cannot read them, because
messages_public_anon_select filters deleted_at.
Fix plan
- New BEFORE trigger. In
scripts/10-3-func-message.sql, after message_soft_delete, add public.clear_deleted_message_text() and its trigger:
BEFORE UPDATE OF deleted_at ON public.messages FOR EACH ROW WHEN (NEW.deleted_at IS NOT NULL).
- It sets
NEW.content, NEW.html and NEW.medias to null, and removes comment from NEW.metadata. The comment anchor quotes document text.
- It keeps
metadata.replied, reply_to_message_id and the row, so threads and reply previews keep their place.
handle_message_soft_delete still receives OLD.medias, so media cleanup is unchanged.
- Backfill the existing rows in the same migration. Run
update public.messages set deleted_at = deleted_at where deleted_at is not null and (content is not null or html is not null or medias is not null);.
- This fires only the new trigger. The
message_soft_delete WHEN clause is false, so nothing is re-broadcast or re-counted.
- Do not set
content directly. That fires update_message_previews and overwrites reply and channel previews.
- Mirror the function and trigger in
packages/supabase/scripts/10-3-func-message.sql. The migration must also create them, because production never runs db reset.
Repo rules that apply:
- Regenerate the seed with
bun run --filter @docs.plus/supabase_back seed. Never hand-edit packages/supabase/seed.sql.
- Run
bun run --filter @docs.plus/supabase_back types. A trigger does not change the generated types, so expect no diff.
Out of scope
- Adding
deleted_at is null to messages_visible_select. Once the text is gone, the leak is closed. The filter would also break two things:
deleteMessage calls .select(), so its UPDATE … RETURNING would fail RLS on the new row.
- Signed-in clients would stop getting the
postgres_changes delete event. The chatroom "Soft-delete broadcast contract" in apps/webapp/src/components/chatroom/CLAUDE.md says they get both signals.
- Purging soft-deleted rows.
Acceptance criteria
Verify
- Apply locally:
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql.
- In the local webapp, send a message with text and a picture, reply to it, then delete it.
- As
postgres: select content, html, medias, metadata from public.messages where id = '<message id>';. Expect null text and media.
- Check the reply preview and the channel preview in the webapp.
- In production after deploy, read-only:
select count(*) from public.messages where deleted_at is not null and (content is not null or html is not null); returns 0.
Related
Generated by Claude Code
Summary
Deleting a chat message only sets
deleted_at. The text stays incontentandhtml. The signed-in read policy does not filterdeleted_at, and every chat channel is PUBLIC. So any signed-in account can read the text of every deleted message through the Data API.messagestriggersProduction check (2026-10-06, read-only)
messages_visible_selectisUSING (internal.can_read_channel(channel_id))with nodeleted_atfilter.Where
deleted_at:apps/webapp/src/api/messages/deleteMessage.ts:3-10.handle_message_soft_deleteand its triggermessage_soft_delete:packages/supabase/scripts/10-3-func-message.sql:21-107and:116-120. The trigger isAFTER UPDATE OF deleted_at, so assigningNEW.contentinside it has no effect.update_message_previews(AFTER UPDATE OF content, medias, type):packages/supabase/scripts/10-3-func-message.sql:161-169. It rewrites reply previews and the channel preview whencontentis in the SET list.messages_visible_selectinpackages/supabase/scripts/13-RLS.sql:191-193.messages_public_anon_selectfiltersdeleted_at.Fix plan
scripts/10-3-func-message.sql, aftermessage_soft_delete, addpublic.clear_deleted_message_text()and its trigger:BEFORE UPDATE OF deleted_at ON public.messages FOR EACH ROW WHEN (NEW.deleted_at IS NOT NULL).NEW.content,NEW.htmlandNEW.mediasto null, and removescommentfromNEW.metadata. The comment anchor quotes document text.metadata.replied,reply_to_message_idand the row, so threads and reply previews keep their place.handle_message_soft_deletestill receivesOLD.medias, so media cleanup is unchanged.update public.messages set deleted_at = deleted_at where deleted_at is not null and (content is not null or html is not null or medias is not null);.message_soft_deleteWHEN clause is false, so nothing is re-broadcast or re-counted.contentdirectly. That firesupdate_message_previewsand overwrites reply and channel previews.packages/supabase/scripts/10-3-func-message.sql. The migration must also create them, because production never runsdb reset.Repo rules that apply:
bun run --filter @docs.plus/supabase_back seed. Never hand-editpackages/supabase/seed.sql.bun run --filter @docs.plus/supabase_back types. A trigger does not change the generated types, so expect no diff.Out of scope
deleted_at is nulltomessages_visible_select. Once the text is gone, the leak is closed. The filter would also break two things:deleteMessagecalls.select(), so its UPDATE … RETURNING would fail RLS on the new row.postgres_changesdelete event. The chatroom "Soft-delete broadcast contract" inapps/webapp/src/components/chatroom/CLAUDE.mdsays they get both signals.Acceptance criteria
content,htmlandmedias.message:deletedbroadcast behave as before.Verify
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<new file>.sql.postgres:select content, html, medias, metadata from public.messages where id = '<message id>';. Expect null text and media.select count(*) from public.messages where deleted_at is not null and (content is not null or html is not null);returns 0.Related
messagescolumns a client may UPDATE. The new trigger sets columns inside a BEFORE trigger, which needs no column grant.Generated by Claude Code