Kort sagt
En behörighetskontroll i en SECURITY DEFINER-funktion löd if not (has_perm or created_by = me or assignee_id = me) then raise. I juni 2026 saknade alla 3683 uppgifter i appen både skapare och ansvarig. false OR NULL OR NULL är NULL, NOT NULL är fortfarande NULL, och IF hoppar över sin kropp när villkoret är NULL. Därför kastades felet aldrig, och vilken inloggad användare som helst kunde klona uppgifter. Åtgärden är coalesce(..., false) runt hela villkoret.
Det här hände i tidrapporteringssystemet för revisionsbyrån, i funktionen bakom “klona uppgift”.
Hur såg kontrollen ut?
if not ( has_perm('tasks', 'create')
or v_task.created_by = auth.uid()
or v_task.assignee_id = auth.uid() ) then
raise exception 'forbidden';
end if;
Den läses som “neka om du inte får skapa uppgifter, inte har gjort den här eller inte äger den”. De 3683 uppgifterna hade seedats automatiskt, så created_by och assignee_id var NULL på varenda en. För en roll med alla behörigheter avstängda blev villkoret NOT (false OR NULL OR NULL), alltså NULL, och raise kördes aldrig.
Varför är samma villkor säkert i RLS?
Row level security behandlar allt som inte är true som ett nekande, så NULL betyder nej. Ett PL/pgSQL-IF behandlar allt som inte är true som “hoppa över kroppen”, och här var kroppen själva nekandet. PostgreSQL-dokumentationen anger båda halvorna, för row security och för IF; tillsammans gör de samma uttryck till ett nekande på ett ställe och ett tillåtande på det andra.
En SECURITY DEFINER-funktion körs dessutom förbi RLS, så dess egen kontroll är den enda som gäller. En enda NULL-gren räcker: de andra grenarna kan alla vara false.
Vad åtgärdade det?
Linda in hela villkoret så att NULL blir ett nekande, och behandla kloning som att skapa en uppgift, inte som att redigera källan:
if not coalesce((select has_perm('tasks', 'create')), false) then
raise exception 'forbidden' using errcode = '42501';
end if;
Hände det igen?
Ja, ungefär tre månader senare, i tre funktioner till: att arkivera en uppgift, att ändra dess typ och att kontrollera om tid får registreras på den. Att läsa SQL-koden fångade dem inte. Beviset roll för roll för RLS gjorde det, eftersom det stänger av varje behörighet och kontrollerar att åtgärden nekas.
Hur söker du igenom koden?
grep 'if not (' hittar syntaxen, inte buggen. Det här fungerade:
- Dumpa varje funktionskropp med
pg_get_functiondefi en enda fråga. - Ta i varje
if not (...) thenochif a <> b thenbortcoalesce(...),exists(...)och(select ...). En nakenor-kedja eller jämförelse över en kolumn som kan vara null är en kandidat. - Matcha också
if not <variable> thenoch följ var variabeln sätts._ok := has_perm(...) or created_by = me;döljer samma bugg på två rader. - Anropa varje hjälpfunktion som används som
if not helper(...)på tre sätt i en transaktion som rullas tillbaka: ett okänt id, ettNULL-argument och en verklig användare vars förväntade rad saknas. En hjälpfunktion som returnerarNULLi något av fallen behöver encoalescevid anropet eller inuti.
I den appen kontrollerade genomgången 14 hjälpfunktioner, som alla redan var säkra. Buggen var begränsad till klonfunktionen och de tre som hittades senare. Ett närliggande multi-tenant-glapp: vad RLS missar för en användare i två organisationer. Appen var en revisionsbyrås tidrapportering; dess övriga tysta fel finns i tidrapportering för revisionsbyråer. Supabase säkerhetskontroll listar SECURITY DEFINER-funktioner bland sina 46 regler, men kan inte avgöra om ett IF inuti en av dem fallerar öppet, så den här genomgången är fortfarande manuell.