Alguns dias atrás, escrevi no blog sobre os problemas comuns com funções e privilégios que descobrimos durante as revisões de segurança.
É claro que o PostgreSQL oferece muitos recursos avançados relacionados à segurança, sendo um deles o Row Level Security (RLS), disponível desde o PostgreSQL 9.5.
Como o 9.5 foi lançado em janeiro de 2016 (apenas alguns meses atrás), o RLS é um recurso relativamente novo e ainda não estamos lidando com muitas implantações de produção. Em vez disso, o RLS é um assunto comum de discussões sobre “como implementar”, e uma das perguntas mais comuns é como fazê-lo funcionar com usuários em nível de aplicativo. Então vamos ver quais são as soluções possíveis.
Introdução ao RLS
Vamos ver primeiro um exemplo muito simples, explicando do que se trata o RLS. Digamos que temos um
chat
tabela armazenando mensagens enviadas entre usuários – os usuários podem inserir linhas nela para enviar mensagens a outros usuários e consultá-la para ver as mensagens enviadas a eles por outros usuários. Então a tabela pode ficar assim:CREATE TABLE chat ( message_uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(), message_time TIMESTAMP NOT NULL DEFAULT now(), message_from NAME NOT NULL DEFAULT current_user, message_to NAME NOT NULL, message_subject VARCHAR(64) NOT NULL, message_body TEXT);
A segurança baseada em função clássica só nos permite restringir o acesso a toda a tabela ou a fatias verticais dela (colunas). Portanto, não podemos usá-lo para impedir que os usuários leiam mensagens destinadas a outros usuários ou enviem mensagens com umamessage_from
falsa campo.
E é exatamente para isso que serve o RLS – ele permite que você crie regras (políticas) restringindo o acesso a subconjuntos de linhas. Então, por exemplo, você pode fazer isso:
CRIAR POLÍTICA chat_policy NO chat USING ((message_to =current_user) OU (message_from =current_user)) WITH CHECK (message_from =current_user)
Esta política garante que um usuário possa ver apenas as mensagens enviadas por ele ou destinadas a ele - essa é a condição emUSING
cláusula faz. A segunda parte da política (WITH CHECK
) garante que um usuário só pode inserir mensagens com seu nome de usuário emmessage_from
coluna, evitando mensagens com remetente forjado.
Você também pode imaginar o RLS como uma maneira automática de anexar condições WHERE adicionais. Você poderia fazer isso manualmente no nível do aplicativo (e antes que as pessoas do RLS fizessem isso), mas o RLS faz isso de maneira confiável e segura (muito esforço foi feito para evitar vários vazamentos de informações, por exemplo).
Observação :Antes do RLS, uma maneira popular de obter algo semelhante era tornar a tabela inacessível diretamente (revogar todos os privilégios) e fornecer um conjunto de funções definidoras de segurança para acessá-la. Isso atingiu basicamente o mesmo objetivo, mas as funções têm várias desvantagens – elas tendem a confundir o otimizador e limitar seriamente a flexibilidade (se o usuário precisar fazer algo e não houver uma função adequada para isso, ele está sem sorte). E, claro, você tem que escrever essas funções.
Usuários do aplicativo
Se você ler a documentação oficial sobre RLS, poderá notar um detalhe – todos os exemplos usamcurrent_user
, ou seja, o usuário atual do banco de dados. Mas não é assim que a maioria dos aplicativos de banco de dados funciona hoje em dia. Aplicativos da Web com muitos usuários registrados não mantêm mapeamento 1:1 para usuários de banco de dados, mas usam um único usuário de banco de dados para executar consultas e gerenciar usuários de aplicativos por conta própria – talvez em umusers
tabela.
Tecnicamente, não é um problema criar muitos usuários de banco de dados no PostgreSQL. O banco de dados deve lidar com isso sem problemas, mas os aplicativos não fazem isso por vários motivos práticos. Por exemplo, eles precisam rastrear informações adicionais para cada usuário (por exemplo, departamento, cargo na organização, detalhes de contato, …), para que o aplicativo precise dosusers
mesa de qualquer maneira.
Outro motivo pode ser o pool de conexões – usando uma única conta de usuário compartilhada, embora saibamos que isso pode ser resolvido usando herança eSET ROLE
(veja o post anterior).
Mas vamos supor que você não queira criar usuários de banco de dados separados – você deseja continuar usando uma única conta de banco de dados compartilhada e usar RLS com usuários de aplicativos. Como fazer isso?
Variáveis de sessão
Essencialmente, o que precisamos é passar contexto adicional para a sessão do banco de dados, para que possamos usá-lo posteriormente na política de segurança (em vez docurrent_user
variável). E a maneira mais fácil de fazer isso no PostgreSQL são as variáveis de sessão:
SET my.username ='tomas'
Se isso se assemelhar aos parâmetros de configuração usuais (por exemplo,SET work_mem = '...'
), você está absolutamente certo – é basicamente a mesma coisa. O comando define um novo namespace (my
), e adiciona umusername
variável nele. O novo namespace é necessário, pois o global é reservado para a configuração do servidor e não podemos adicionar novas variáveis a ele. Isso nos permite alterar a política de segurança assim:
CRIAR POLÍTICA chat_policy NO chat USING (current_setting('my.username') IN (message_from, message_to)) WITH CHECK (message_from =current_setting('my.username'))
Tudo o que precisamos fazer é garantir que o pool de conexões/aplicativo defina o nome do usuário sempre que obtiver uma nova conexão e o atribua à tarefa do usuário.
Deixe-me salientar que essa abordagem entra em colapso quando você permite que os usuários executem SQL arbitrário na conexão ou se o usuário conseguir descobrir uma vulnerabilidade de injeção de SQL adequada. Nesse caso, não há nada que possa impedi-los de definir um nome de usuário arbitrário. Mas não se desespere, há um monte de soluções para esse problema, e vamos passar por elas rapidamente.
Variáveis de sessão assinadas
A primeira solução é uma simples melhoria das variáveis de sessão – não podemos impedir que os usuários definam valores arbitrários, mas e se pudéssemos verificar se o valor não foi subvertido? Isso é bastante fácil de fazer usando uma assinatura digital simples. Em vez de apenas armazenar o nome de usuário, a parte confiável (pool de conexões, aplicativo) pode fazer algo assim:
assinatura =sha256(nome de usuário + carimbo de data/hora + SEGREDO)
e, em seguida, armazene o valor e a assinatura na variável de sessão:
SET my.username ='username:timestamp:signature'
Supondo que o usuário não conheça a string SECRET (por exemplo, 128B de dados aleatórios), não deve ser possível modificar o valor sem invalidar a assinatura.
Observação :Esta não é uma ideia nova – é essencialmente a mesma coisa que cookies HTTP assinados. O Django tem uma documentação bem legal sobre isso.
A maneira mais fácil de proteger o valor SECRET é armazená-lo em uma tabela inacessível pelo usuário e fornecer umsecurity definer
função, exigindo uma senha (para que o usuário não possa simplesmente assinar valores arbitrários).
CREATE FUNCTION set_username(uname TEXT, pwd TEXT) RETORNA texto AS $DECLARE v_key TEXT; v_value TEXT;BEGIN SELECT sign_key INTO v_key FROM segredos; v_value :=uname || ':' || extract(época a partir de agora())::int; v_value :=v_value || ':' || crypt(v_value || ':' || v_key, gen_salt('bf')); PERFORM set_config('my.username', v_value, false); RETURN v_value;END;$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
A função simplesmente procura a chave de assinatura (secreta) em uma tabela, calcula a assinatura e então define o valor na variável de sessão. Ele também retorna o valor, principalmente por conveniência.
Portanto, a parte confiável pode fazer isso antes de entregar a conexão ao usuário (obviamente ‘passphrase’ não é uma senha muito boa para produção):
SELECT set_username('tomas', 'senha')
E é claro que precisamos de outra função que simplesmente verifique a assinatura e dê erros ou retorne o nome de usuário se a assinatura corresponder.
CRIAR FUNÇÃO get_username() RETORNA texto AS $DECLARE v_key TEXT; v_parts TEXT[]; v_uname TEXTO; v_value TEXTO; v_timestamp INT; v_signature TEXT;BEGIN -- sem verificação de senha desta vez SELECT sign_key INTO v_key FROM secrets; v_parts :=regexp_split_to_array(current_setting('my.username', true), ':'); v_uname :=v_parts[1]; v_timestamp :=v_parts[2]; v_signature :=v_parts[3]; v_value :=v_uname || ':' || v_timestamp || ':' || v_key; SE v_signature =crypt(v_value, v_signature) ENTÃO RETURN v_uname; FIM SE; RAISE EXCEPTION 'nome de usuário / timestamp inválido';END;$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
E como esta função não precisa da senha, o usuário pode simplesmente fazer isso:
SELECT get_username()
Mas oget_username()
função destina-se a políticas de segurança, por exemplo assim:
CRIAR POLÍTICA chat_policy NO chat USANDO (get_username() IN (message_from, message_to)) WITH CHECK (message_from =get_username())
Um exemplo mais completo, compactado como uma extensão simples, pode ser encontrado aqui.
Observe que todos os objetos (tabela e funções) são de propriedade de um usuário privilegiado, não do usuário que acessa o banco de dados. O usuário tem apenasEXECUTE
privilégio nas funções, que são definidas comoSECURITY DEFINER
. É isso que faz esse esquema funcionar enquanto protege o segredo do usuário. As funções são definidas comoSTABLE
, para limitar o número de chamadas para ocrypt()
função (que é intencionalmente cara para evitar força bruta).
As funções de exemplo definitivamente precisam de mais trabalho. Mas espero que seja bom o suficiente para uma prova de conceito demonstrando como armazenar contexto adicional em uma variável de sessão protegida.
O que precisa ser corrigido, você pergunta? Em primeiro lugar, as funções não lidam muito bem com várias condições de erro. Em segundo lugar, embora o valor assinado inclua um carimbo de data/hora, não estamos fazendo nada com ele – ele pode ser usado para expirar o valor, por exemplo. É possível adicionar bits adicionais ao valor, por exemplo, um departamento do usuário, ou mesmo informações sobre a sessão (por exemplo, PID do processo de backend para evitar a reutilização do mesmo valor em outras conexões).
Cripto
As duas funções dependem da criptografia – não estamos usando muito, exceto algumas funções simples de hash, mas ainda é um esquema de criptografia simples. E todo mundo sabe que você não deve fazer sua própria criptomoeda. É por isso que usei a extensão pgcrypto, particularmente acrypt()
função, para contornar este problema. Mas eu não sou um criptógrafo, então, embora eu acredite que todo o esquema esteja bem, talvez eu esteja perdendo alguma coisa - me avise se você encontrar algo.
Além disso, a assinatura seria uma ótima combinação para criptografia de chave pública – poderíamos usar uma chave PGP comum com uma senha para a assinatura e a parte pública para verificação de assinatura. Infelizmente, embora o pgcrypto suporte PGP para criptografia, ele não suporta a assinatura.
Abordagens alternativas
Claro, existem várias soluções alternativas. Por exemplo, em vez de armazenar o segredo de assinatura em uma tabela, você pode codificá-lo na função (mas você precisa garantir que o usuário não possa ver o código-fonte). Ou você pode fazer a assinatura em uma função C, nesse caso, ela fica oculta de todos que não têm acesso à memória (nesse caso, você perdeu de qualquer maneira).
Além disso, se você não gostar da abordagem de assinatura, poderá substituir a variável assinada por uma solução de “cofre” mais tradicional. Precisamos de uma maneira de armazenar os dados, mas precisamos garantir que o usuário não possa ver ou modificar o conteúdo arbitrariamente, exceto de uma maneira definida. Mas ei, é isso que tabelas regulares com uma API implementadas usandosecurity definer
funções podem fazer!
Não vou apresentar todo o exemplo reformulado aqui (verifique esta extensão para um exemplo completo), mas o que precisamos é desessions
mesa atuando como o cofre:
CREATE TABLE sessões ( session_id UUID PRIMARY KEY, session_user NAME NOT NULL)
A tabela não deve ser acessível por usuários regulares do banco de dados – um simplesREVOKE ALL FROM ...
deveria cuidar disso. E então uma API que consiste em duas funções principais:
set_username(user_name, passphrase)
– gera um UUID aleatório, insere dados no cofre e armazena o UUID em uma variável de sessãoget_username()
– lê o UUID de uma variável de sessão e procura a linha na tabela (erros se não houver linha correspondente)
Essa abordagem substitui a proteção de assinatura pela aleatoriedade do UUID – o usuário pode ajustar a variável de sessão, mas a probabilidade de atingir um ID existente é insignificante (os UUIDs são valores aleatórios de 128 bits).
É uma abordagem um pouco mais tradicional, contando com a segurança tradicional baseada em função, mas também tem algumas desvantagens – por exemplo, ele realmente grava em banco de dados, o que significa que é inerentemente incompatível com sistemas de espera ativa.
Livrar-se da senha
Também é possível projetar o cofre para que a senha não seja necessária. Nós o introduzimos porque assumimos
set_username
acontece na mesma conexão – temos que manter a função executável (então mexer com funções ou privilégios não é uma solução), e a senha garante que apenas o componente confiável possa realmente usá-la. Mas e se a assinatura/criação da sessão acontecer em uma conexão separada e apenas o resultado (valor assinado ou UUID da sessão) for copiado na conexão entregue ao usuário? Bem, então não precisamos mais da senha. (É um pouco semelhante ao que o Kerberos faz – gerar um tíquete em uma conexão confiável e usar o tíquete para outros serviços.)
Resumo
Então deixe-me recapitular rapidamente este post do blog:
- Embora todos os exemplos de RLS usem usuários de banco de dados (por meio de
current_user
), não é muito difícil fazer o RLS funcionar com usuários de aplicativos. - As variáveis de sessão são uma solução confiável e bastante simples, supondo que o sistema tenha um componente confiável que possa definir a variável antes de entregar a conexão a um usuário.
- Quando o usuário pode executar SQL arbitrário (por design ou devido a uma vulnerabilidade), uma variável assinada impede que o usuário altere o valor.
- Outras soluções são possíveis, por exemplo. substituindo as variáveis de sessão por uma tabela que armazena informações sobre sessões identificadas por UUID aleatório.
- Uma coisa legal é que as variáveis de sessão não gravam no banco de dados, portanto, essa abordagem pode funcionar em sistemas somente leitura (por exemplo, hot standby).
Na próxima parte desta série de blogs, veremos como usar usuários de aplicativos quando o sistema não possui um componente confiável (portanto, ele não pode definir a variável de sessão ou criar uma linha nas
sessions
table), ou quando queremos realizar autenticação personalizada (adicional) dentro do banco de dados.