---
title: "NULL en IF NOT de PL/pgSQL: saltó el control en 3683 tareas"
description: "En una función SECURITY DEFINER, false OR NULL es NULL e IF NOT salta el RAISE. Por qué es seguro en RLS, la corrección con coalesce y cómo buscarlo."
url: "https://madsalt.tech/es/notes/plpgsql-if-not-null-fails-open/"
author: "Denys Zaichenko"
language: "es"
published: 2026-10-18
---

# NULL en IF NOT de PL/pgSQL: saltó el control en 3683 tareas

En resumen

Un control de permisos dentro de una función `SECURITY DEFINER` decía `if not (has_perm or created_by = me or assignee_id = me) then raise`. En junio de 2026, las 3683 tareas de la aplicación no tenían creador ni responsable. `false OR NULL OR NULL` es `NULL`, `NOT NULL` sigue siendo `NULL`, y `IF` salta su cuerpo cuando la condición es `NULL`. Así que el error nunca se lanzó, y cualquier usuario con sesión podía clonar tareas. La corrección es `coalesce(..., false)` alrededor de toda la condición.

Fue en el [control de horas de una firma de auditoría](https://madsalt.tech/es/work/audit-firm-time-tracking/), en la función detrás de "clonar tarea".

## ¿Cómo era el control?

```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;
```

Se lee como "rechazar salvo que pueda crear tareas, haya creado esta o sea suya". Las 3683 tareas se habían sembrado automáticamente, así que `created_by` y `assignee_id` eran `NULL` en todas. Para un rol con todos los permisos desactivados, la condición era `NOT (false OR NULL OR NULL)`, que es `NULL`, y el `raise` nunca se ejecutaba.

## ¿Por qué la misma condición es segura en RLS?

Row level security trata todo lo que no sea `true` como un rechazo, así que `NULL` significa no. Un `IF` de PL/pgSQL trata todo lo que no sea `true` como "saltar el cuerpo", y aquí el cuerpo era el rechazo. La documentación de PostgreSQL enuncia las dos mitades, para [row security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) y para [IF](https://www.postgresql.org/docs/current/plpgsql-control-structures.html); juntas convierten la misma expresión en un rechazo en un sitio y en un permiso en el otro.

![El mismo NULL, dos respuestas opuestas. has_perm es false, created_by y assignee_id son NULL. En una política RLS: false OR NULL → NULL → Fila rechazada; En IF NOT (...) THEN RAISE: false OR NULL → NOT NULL es NULL → RAISE omitido → Clonado permitido.](https://madsalt.tech/charts/es/plpgsql-if-not-null-fails-open.svg)

*Lógica de tres valores tal como la describe la documentación de PostgreSQL 18, secciones 5.9 (row security) y 41.6.4 (IF).*

Una función `SECURITY DEFINER` además se ejecuta por encima de RLS, así que su propio control es el único que se aplica. Basta una rama `NULL`: las demás pueden ser todas `false`.

## ¿Qué lo arregló?

Envuelva toda la condición para que `NULL` se convierta en rechazo, y trate clonar como crear una tarea, no como editar la original:

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

## ¿Volvió a pasar?

Sí, unos tres meses después, en tres funciones más: archivar una tarea, cambiar su tipo y comprobar si se puede registrar tiempo en ella. Leer el SQL no las detectó. La [prueba de RLS rol a rol](https://madsalt.tech/es/blog/how-to-test-supabase-rls/) sí, porque desactiva cada permiso y comprueba que la acción se rechaza.

## ¿Cómo buscarlo?

`grep 'if not ('` encuentra la sintaxis, no el bug. Lo que funcionó:

1. Volcar el cuerpo de todas las funciones con `pg_get_functiondef` en una sola consulta.
2. En cada `if not (...) then` y `if a <> b then`, quitar `coalesce(...)`, `exists(...)` y `(select ...)`. Una cadena de `or` desnuda o una comparación sobre una columna que admite nulos es candidata.
3. Buscar también `if not <variable> then` y rastrear dónde se asigna la variable. `_ok := has_perm(...) or created_by = me;` esconde el mismo bug en dos líneas.
4. Llamar a cada helper usado como `if not helper(...)` de tres maneras dentro de una transacción que se revierte: un id desconocido, un argumento `NULL` y un usuario real al que le falta la fila esperada. Un helper que devuelve `NULL` en cualquiera de ellas necesita un `coalesce` en el punto de llamada o dentro.

En esa aplicación la búsqueda revisó 14 helpers, todos ya seguros. El bug quedó limitado a la función de clonar y a las tres encontradas después. Un hueco relacionado en multi-tenant: [lo que RLS no cubre para un usuario en dos orgs](https://madsalt.tech/es/notes/supabase-rls-user-in-two-organizations/). La aplicación era el control de horas de una firma de auditoría; sus otros errores silenciosos están en [control de horas en una firma de auditoría](https://madsalt.tech/es/blog/time-tracking-for-audit-firms/). La [Auditoría de seguridad de Supabase](https://madsalt.tech/es/tools/supabase-security-check/) incluye las funciones SECURITY DEFINER entre sus 46 reglas, pero no puede saber si un IF dentro de una de ellas falla en abierto, así que este barrido sigue siendo manual.
