Saltar al contenido

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

Postgres

Ver como MarkdownAbrir en ClaudeAbrir en ChatGPTAbrir en Perplexity

La pregunta también se copia. Si el chat se abre vacío, péguela.

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, en la función detrás de “clonar tarea”.

¿Cómo era el control?

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 y para IF; 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.
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:

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 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. 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. La Auditoría de seguridad de Supabase 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.

© 2026 Denys Zaichenko. Artículo original: https://madsalt.tech/es/notes/plpgsql-if-not-null-fails-open/