Aller au contenu

Un NULL dans IF NOT a sauté le contrôle sur 3683 tâches

Postgres

Voir en MarkdownOuvrir dans ClaudeOuvrir dans ChatGPTOuvrir dans Perplexity

La question est aussi copiée. Si le chat reste vide, collez-la.

En bref

Un contrôle de permission dans une fonction SECURITY DEFINER s’écrivait if not (has_perm or created_by = me or assignee_id = me) then raise. En juin 2026, les 3683 tâches de l’application n’avaient ni créateur ni assigné. false OR NULL OR NULL vaut NULL, NOT NULL vaut toujours NULL, et IF saute son corps quand la condition est NULL. L’erreur n’a donc jamais été levée, et tout utilisateur connecté pouvait cloner des tâches. La correction est coalesce(..., false) autour de toute la condition.

C’était dans le suivi du temps d’un cabinet d’audit, dans la fonction derrière « cloner la tâche ».

À quoi ressemblait le contrôle ?

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;

On le lit comme « refuser sauf si vous pouvez créer des tâches, si vous avez créé celle-ci ou si elle est à vous ». Les 3683 tâches avaient été créées automatiquement, donc created_by et assignee_id valaient NULL sur chacune. Pour un rôle dont toutes les permissions étaient désactivées, la condition était NOT (false OR NULL OR NULL), soit NULL, et le raise ne s’exécutait jamais.

Pourquoi la même condition est-elle sûre en RLS ?

La row level security traite tout ce qui n’est pas true comme un refus, donc NULL veut dire non. Un IF PL/pgSQL traite tout ce qui n’est pas true comme « sauter le corps », et ici le corps était le refus. La documentation de PostgreSQL énonce les deux moitiés, pour la row security et pour IF ; ensemble, elles font de la même expression un refus à un endroit et une autorisation à l’autre.

Le même NULL, deux réponses opposées. has_perm vaut false, created_by et assignee_id valent NULL. Dans une politique RLS: false OR NULL → NULL → Ligne refusée; Dans IF NOT (...) THEN RAISE: false OR NULL → NOT NULL vaut NULL → RAISE sauté → Clonage autorisé.
Logique à trois valeurs telle que la décrit la documentation de PostgreSQL 18, sections 5.9 (row security) et 41.6.4 (IF).

Une fonction SECURITY DEFINER s’exécute aussi en dehors de la RLS : son propre contrôle est donc le seul qui s’applique. Un seul bras NULL suffit : les autres peuvent tous valoir false.

Qu’est-ce qui l’a corrigé ?

Enveloppez toute la condition pour que NULL devienne un refus, et traitez le clonage comme la création d’une tâche, pas comme la modification de la source :

if not coalesce((select has_perm('tasks', 'create')), false) then
  raise exception 'forbidden' using errcode = '42501';
end if;

Est-ce arrivé de nouveau ?

Oui, environ trois mois plus tard, dans trois autres fonctions : archiver une tâche, changer son type et vérifier si du temps peut y être saisi. La lecture du SQL ne les a pas repérées. La preuve de la RLS rôle par rôle l’a fait, car elle désactive chaque permission et vérifie que l’action est refusée.

Comment balayer le code ?

grep 'if not (' trouve la syntaxe, pas le bug. Ce qui a marché :

  1. Extraire le corps de toutes les fonctions avec pg_get_functiondef en une seule requête.
  2. Dans chaque if not (...) then et if a <> b then, retirer coalesce(...), exists(...) et (select ...). Une chaîne de or ou une comparaison sur une colonne nullable est une candidate.
  3. Chercher aussi if not <variable> then et retrouver où la variable est affectée. _ok := has_perm(...) or created_by = me; cache le même bug sur deux lignes.
  4. Appeler chaque helper utilisé en if not helper(...) de trois façons dans une transaction annulée : un identifiant inconnu, un argument NULL, et un vrai utilisateur dont la ligne attendue manque. Un helper qui renvoie NULL dans l’un de ces cas a besoin d’un coalesce à l’appel ou à l’intérieur.

Dans cette application, le balayage a vérifié 14 helpers, tous déjà sûrs. Le bug se limitait à la fonction de clonage et aux trois trouvées ensuite. Une faille voisine pour le multi-tenant : ce que la RLS laisse passer pour un utilisateur dans deux organisations. L’application était l’outil de suivi des temps d’un cabinet d’audit ; ses autres erreurs silencieuses sont dans suivi des temps en cabinet d’audit. L’Audit de sécurité Supabase liste les fonctions SECURITY DEFINER parmi ses 46 règles, mais il ne peut pas dire si un IF à l’intérieur échoue en laissant passer, donc ce balayage reste manuel.

© 2026 Denys Zaichenko. Article original : https://madsalt.tech/fr/notes/plpgsql-if-not-null-fails-open/