---
title: "Un NULL dans IF NOT a sauté le contrôle sur 3683 tâches"
description: "Dans une fonction SECURITY DEFINER, false OR NULL vaut NULL et IF NOT saute le RAISE. Pourquoi c'est sûr en RLS, la correction coalesce, comment balayer."
url: "https://madsalt.tech/fr/notes/plpgsql-if-not-null-fails-open/"
author: "Denys Zaichenko"
language: "fr"
published: 2026-10-18
---

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

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](https://madsalt.tech/fr/work/audit-firm-time-tracking/), dans la fonction derrière « cloner la tâche ».

## À quoi ressemblait le contrôle ?

```sql
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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) et pour [IF](https://www.postgresql.org/docs/current/plpgsql-control-structures.html) ; 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é.](https://madsalt.tech/charts/fr/plpgsql-if-not-null-fails-open.svg)

*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 :

```sql
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](https://madsalt.tech/fr/blog/how-to-test-supabase-rls/) 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](https://madsalt.tech/fr/notes/supabase-rls-user-in-two-organizations/). 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](https://madsalt.tech/fr/blog/time-tracking-for-audit-firms/). L'[Audit de sécurité Supabase](https://madsalt.tech/fr/tools/supabase-security-check/) 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.
