Saltar para o conteúdo

NULL num IF NOT de PL/pgSQL saltou a regra em 3683 tarefas

Postgres

Ver como MarkdownAbrir em ClaudeAbrir em ChatGPTAbrir em Perplexity

A pergunta também é copiada. Se o chat abrir vazio, cole-a.

Em resumo

Uma verificação de permissões dentro de uma função SECURITY DEFINER lia if not (has_perm or created_by = me or assignee_id = me) then raise. Em junho de 2026, as 3683 tarefas da aplicação não tinham criador nem responsável. false OR NULL OR NULL é NULL, NOT NULL continua NULL, e o IF salta o corpo quando a condição é NULL. Por isso o erro nunca era lançado, e qualquer utilizador com sessão iniciada podia clonar tarefas. A correção é coalesce(..., false) à volta de toda a condição.

Isto foi no registo de horas da empresa de auditoria, na função por trás de “clonar tarefa”.

Como era a verificação?

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;

Lê-se como “recusar, a menos que possa criar tarefas, a tenha criado ou seja o responsável”. As 3683 tarefas tinham sido criadas automaticamente, por isso created_by e assignee_id eram NULL em todas. Para uma função com todas as permissões desligadas, a condição era NOT (false OR NULL OR NULL), que é NULL, e o raise nunca executava.

Porque é que a mesma condição é segura no RLS?

O row level security trata tudo o que não é true como recusa, por isso NULL significa não. Um IF de PL/pgSQL trata tudo o que não é true como “saltar o corpo”, e aqui o corpo era a recusa. A documentação do PostgreSQL descreve as duas metades, para row security e para IF; juntas, fazem da mesma expressão uma recusa num sítio e uma autorização no outro.

O mesmo NULL, duas respostas opostas. has_perm é false, created_by e assignee_id são NULL. Numa política de RLS: false OR NULL → NULL → Linha recusada; Em IF NOT (...) THEN RAISE: false OR NULL → NOT NULL é NULL → RAISE saltado → Clone permitido.
Lógica de três valores como a descreve a documentação do PostgreSQL 18, secções 5.9 (row security) e 41.6.4 (IF).

Uma função SECURITY DEFINER também passa por cima do RLS, por isso a sua própria verificação é a única que vale. Basta um ramo NULL: os outros podem ser todos false.

O que resolveu?

Envolver toda a condição para que NULL passe a ser recusa, e tratar a clonagem como a criação de uma tarefa, não como a edição da original:

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

Voltou a acontecer?

Sim, cerca de três meses depois, em mais três funções: arquivar uma tarefa, mudar-lhe o tipo e verificar se se pode registar tempo nela. Ler o SQL não as apanhou. A prova de RLS função a função apanhou, porque desliga cada permissão e verifica que a ação é recusada.

Como procurar o problema?

grep 'if not (' encontra a sintaxe, não o bug. O que funcionou:

  1. Extrair o corpo de todas as funções com pg_get_functiondef, numa só consulta.
  2. Em cada if not (...) then e if a <> b then, retirar coalesce(...), exists(...) e (select ...). Uma cadeia de or ou uma comparação sobre uma coluna anulável, sem mais nada, é candidata.
  3. Procurar também if not <variável> then e seguir onde a variável é definida. _ok := has_perm(...) or created_by = me; esconde o mesmo bug em duas linhas.
  4. Chamar cada helper usado como if not helper(...) de três formas, dentro de uma transação revertida: um id desconhecido, um argumento NULL e um utilizador real cuja linha esperada não existe. Um helper que devolva NULL em qualquer delas precisa de coalesce no ponto de chamada ou dentro dele.

Nessa aplicação, a pesquisa verificou 14 helpers, todos já seguros. O bug ficou restrito à função de clonagem e às três encontradas mais tarde. Uma falha relacionada em aplicações multi-tenant: o que o RLS não apanha num utilizador em duas orgs. A aplicação era o registo de horas de uma sociedade de auditoria; os seus outros erros silenciosos estão em registo de horas em sociedades de auditoria. A Auditoria de segurança Supabase inclui as funções SECURITY DEFINER entre as suas 46 regras, mas não consegue saber se um IF dentro de uma delas falha em aberto, por isso esta pesquisa continua manual.

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