From 12f187c21395a741e559e696e324b9d7ed55be28 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:17:12 +0200 Subject: [PATCH] fix(cat): you could not read the Cat's answer on your own private post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ask the Cat under a private post and the answer is written, correctly, with the parent's visibility — a private thread should stay private. But the reply is authored by the CAT, and the rule for a private event is `actor_id = auth.uid()`. So the answer to your own question was visible to exactly one account, and it was not yours. Measured in production 2026-08-28 on post 5c3ad8ef. Three replies exist: 425a3d00 actor=cat private "Each person would owe 1,400 CHF" 52cc045c actor=mao private "@cat what is this" 3f9372ba actor=mao public "awdaw" The author could see two of them. The missing one was the answer they had asked for. Nothing errors; the thread renders without it, which reads as the Cat having ignored you. THE RULE ADDED: a private event is also visible to the author of the private post it replies to. Scoped to a private PARENT deliberately. The looser version — "the parent's author sees any private reply" — is a real privacy regression on PUBLIC posts, where anyone may reply and someone may write a private note to themselves under a stranger's post. That note stays theirs. With a private parent the set of possible repliers is closed: to reply to a private post you must first be able to read it, and the policy allows only its author. The one other writer is the Cat, whose worker uses the service role. So this exposes your own replies and the Cat's answers on your own private posts, and nothing else. Verified: a different user still sees one reply on that thread (the public one) and zero private events that are not theirs. The check runs in a SECURITY DEFINER function because a policy on timeline_events that queries timeline_events re-enters itself — "infinite recursion detected in policy for relation timeline_events", which is how the first attempt failed. It answers only "does the caller own this private parent" and returns no row content. Not keyed on the Cat's id or on metadata.is_cat_reply: either would put a second definition of who the Cat is into SQL, to drift against config/cat-identity.ts. The property that matters is structural. GATED, because the failure is silent — the Cat answers, the row exists, and the thread renders without it. timeline_policy_allows_own_thread() reports false against the policy in production today and true after this migration, so a future rewrite that drops the clause turns the nightly run red instead of quietly breaking the feature again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- scripts/check-data-invariants.mjs | 32 +++++ ...e_cats_answer_on_your_own_private_post.sql | 126 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 supabase/migrations/20260828200000_you_cannot_read_the_cats_answer_on_your_own_private_post.sql diff --git a/scripts/check-data-invariants.mjs b/scripts/check-data-invariants.mjs index a0a6a2e4e..1b9eea212 100644 --- a/scripts/check-data-invariants.mjs +++ b/scripts/check-data-invariants.mjs @@ -425,6 +425,37 @@ async function checkBrokenFunctions() { } } +/** + * You can read the Cat's answer on your own private post. + * + * The Cat replies with the parent's visibility, which is correct, but the reply + * is authored by the CAT — and the rule for a private event is + * `actor_id = auth.uid()`. So on 2026-08-28 the answer to a private question + * was visible to exactly one account, and it was not the asker's. Measured on + * post 5c3ad8ef: three replies existed, the author could see two, and the + * missing one was the answer they had asked for. + * + * Gated here because the failure is SILENT. Nothing errors — the Cat answers, + * the row exists, and the thread simply renders without it, which reads as the + * Cat having ignored you. Whoever rewrites this policy next gets no warning if + * the clause goes. + */ +async function checkCatAnswersAreReadable() { + const allowed = await rpc('timeline_policy_allows_own_thread'); + + if (allowed !== true) { + violation( + 'timeline.own_thread_readable', + `the timeline SELECT policy no longer lets you read replies on your own private posts, so ` + + `the Cat answers private questions where the person who asked cannot see them — the reply ` + + `is written, and the thread renders as though it never came`, + [] + ); + } else { + notes.push('timeline: you can read replies on your own private posts'); + } +} + async function checkOrphanedProfiles() { const count = Number(await rpc('count_orphaned_profiles')); @@ -558,6 +589,7 @@ async function main() { checkOrphanedProfiles, checkEmailDerivedUsernames, checkCatHandle, + checkCatAnswersAreReadable, checkBrokenFunctions, checkOrphanedCatConversations, checkOrphanedActors, diff --git a/supabase/migrations/20260828200000_you_cannot_read_the_cats_answer_on_your_own_private_post.sql b/supabase/migrations/20260828200000_you_cannot_read_the_cats_answer_on_your_own_private_post.sql new file mode 100644 index 000000000..044a82c14 --- /dev/null +++ b/supabase/migrations/20260828200000_you_cannot_read_the_cats_answer_on_your_own_private_post.sql @@ -0,0 +1,126 @@ +-- Ask the Cat on a private post and nobody can read the answer, including you. +-- +-- The Cat replies with the PARENT'S visibility, which is right: a private +-- thread should stay private. But the reply is authored by the CAT, and the +-- rule for a private event is `actor_id = auth.uid()`. So the answer to your +-- own question is visible to exactly one account, and it is not yours. +-- +-- Verified in production 2026-08-28 on post 5c3ad8ef. Three replies exist: +-- +-- 425a3d00 actor=cat visibility=private "Each person would owe 1,400 CHF" +-- 52cc045c actor=mao visibility=private "@cat what is this" +-- 3f9372ba actor=mao visibility=public "awdaw" +-- +-- The thread rendered two of them. The Cat's answer — the one that was asked +-- for — was filtered out by RLS for the person who asked. Nothing errors; the +-- reply simply is not there, which reads as the Cat having ignored you. +-- +-- THE RULE ADDED: a private event is also visible to the author of the private +-- post it replies to. +-- +-- Scoped to a private PARENT deliberately. The looser version — "the parent's +-- author can see any private reply" — would be a real privacy regression on +-- PUBLIC posts, where anyone can reply and someone may deliberately write a +-- private note to themselves attached to a stranger's post. That note must stay +-- theirs. +-- +-- Restricted to a private parent, the set of possible repliers is closed: to +-- reply to a private post you must first be able to READ it, and the existing +-- policy allows only its author. The one other writer is the Cat, whose worker +-- uses the service role and bypasses RLS by design. So this exposes your own +-- replies and the Cat's answers on your own private posts, and nothing else. +-- +-- Deliberately not keyed on the Cat's id or on `metadata->>'is_cat_reply'`: +-- either would put a second definition of who the Cat is into SQL, to drift +-- against config/cat-identity.ts. The property that matters is structural — it +-- is a reply to a private post of yours — and that is what is expressed. + +-- The check has to run OUTSIDE row-level security. Asking about +-- `timeline_events` from inside a policy on `timeline_events` re-enters the +-- same policy: "infinite recursion detected in policy for relation +-- timeline_events". A SECURITY DEFINER function reads the parent row directly, +-- so the policy asks a question instead of running a subquery. +-- +-- It answers only "does the caller own this private parent", never returning +-- any row content, so it cannot be used to read a post you may not see. +CREATE OR REPLACE FUNCTION public.owns_private_parent_event(p_parent_id uuid) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path TO 'public' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.timeline_events parent + WHERE parent.id = p_parent_id + AND parent.visibility = 'private' + AND parent.actor_id = (SELECT auth.uid()) + ); +$$; + +REVOKE ALL ON FUNCTION public.owns_private_parent_event(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.owns_private_parent_event(uuid) TO anon, authenticated, service_role; + +COMMENT ON FUNCTION public.owns_private_parent_event(uuid) IS + 'Does the current user own this private post? Used by the timeline_events SELECT policy to let you read replies on your own private posts (chiefly the Cat''s answers) without the policy recursing into its own table.'; + +-- A gate for the nightly data-invariant run. +-- +-- The failure this migration fixes is SILENT: the Cat answers, the row exists, +-- and the person who asked simply sees nothing. Whoever rewrites this policy +-- next will not get an error if they drop the clause — they will get a feature +-- that quietly stops working again. So the check is on the policy itself. +-- +-- Returns a boolean rather than the policy text: what the timeline is visible +-- to is not something to hand out over PostgREST. +CREATE OR REPLACE FUNCTION public.timeline_policy_allows_own_thread() +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +STABLE +SET search_path TO 'public' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'timeline_events' + AND cmd = 'SELECT' + AND qual LIKE '%owns_private_parent_event%' + ); +$$; + +REVOKE ALL ON FUNCTION public.timeline_policy_allows_own_thread() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.timeline_policy_allows_own_thread() FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.timeline_policy_allows_own_thread() TO service_role; + +COMMENT ON FUNCTION public.timeline_policy_allows_own_thread() IS + 'Whether the timeline SELECT policy still lets you read replies on your own private posts. False means the Cat answers your private questions where you cannot see them.'; + +DROP POLICY IF EXISTS "Timeline events viewable by visibility rules" ON public.timeline_events; + +CREATE POLICY "Timeline events viewable by visibility rules" + ON public.timeline_events + FOR SELECT + USING ( + (NOT is_deleted) + AND ( + visibility = 'public' + OR ( + visibility = 'followers' + AND EXISTS ( + SELECT 1 FROM follows + WHERE follows.follower_id = (SELECT auth.uid()) + AND follows.following_id = timeline_events.actor_id + ) + ) + OR (visibility = 'private' AND actor_id = (SELECT auth.uid())) + -- New: a reply on your own private post. Without this the Cat's answer + -- to your question is readable only by the Cat. + OR ( + visibility = 'private' + AND parent_event_id IS NOT NULL + AND public.owns_private_parent_event(parent_event_id) + ) + ) + );