← All posts

Auditamos nosso próprio servidor de autenticação antes do lançamento. Os bugs tinham um formato.

Authagonal·September 3, 2026
securityauditsamloidcmfascimssrfwar-story

Antes de pedir a alguém que confiasse os próprios logins a nós, revisamos nosso próprio servidor de autenticação como um atacante faria: não executando, mas lendo. O que saiu disso foi uma revisão escrita de toda a superfície: dezesseis seções numeradas, 261 linhas, de SAML a federação OIDC, SCIM, API de administração, MFA e ferramentas de backup, cada achado carregando uma severidade e um local no código. Oito foram marcados como corrigir primeiro. Corrigimos esses oito em uma noite e o resto da lista meia hora depois. Duas semanas e meia depois disso, rodamos o mesmo exercício sobre o control plane multi-tenant e tiramos mais cinco, fechados na véspera de entrarmos em produção.

Eis o número que ficou. A suíte de testes reportou 367 de 367 passando antes daquela noite, e 367 de 367 depois dela. Nada ficou vermelho, porque nada na suíte apontava para nenhuma dessas coisas. Testes provam aquilo que você lembrou de testar, e ninguém tinha lembrado de testar se remover um atributo XML que a assinatura não cobre levaria uma assertion repetida por um caminho mais amigável.

Lida como oito bugs sem relação entre si, uma lista dessas é inútil para qualquer outra pessoa. Lida como formatos, são três hábitos, repetidos por vários subsistemas, em código escrito por gente que tinha lido as especificações.

O client_id, o header encaminhado, o InResponseTo e a claim de email

O primeiro hábito é tratar um valor que quem chama escreve como um fato sobre quem chama. Os dois que a revisão marcou como Critical eram, cada um, uma única entrada: um parâmetro de query e uma claim de email.

A política de MFA era derivada do client_id extraído do returnUrl no formulário de login, e /connect/authorize nunca reconferia. Aponte o login para um client com MFA desativado e a sessão saía sem segundo fator, e depois era gasta tranquilamente no client que exigia um. Esse tem um texto só dele.

Os limites por IP usavam o endereço remoto como chave, e headers encaminhados eram confiáveis vindos de 0.0.0.0/0. Um valor de X-Forwarded-For por requisição tornava todo controle por IP meramente consultivo, e ainda reescrevia a coluna de IP no log de auditoria de brinde. Os detalhes estão aqui.

O consumidor de assertions SAML escolhia entre o caminho SP-initiated (consumir o id da requisição, uso único) e o caminho IdP-initiated (conferir um cache de ids de assertion) puramente com base em a response carregar ou não um atributo InResponseTo. Esse atributo fica no elemento <Response>, que não é coberto quando apenas a assertion é assinada, que é o caso comum. Remova-o e uma response capturada seguia pelo caminho cujo cache de replay o login original nunca tinha populado, com todas as assinaturas ainda verificando. Por que a assinatura nunca foi o problema.

E usuários federados que voltavam eram resolvidos pelo email na assertion, então qualquer conexão podia afirmar o endereço de outra pessoa e cair na conta dela. Esse também está escrito.

A API de administração era uma fábrica da própria credencial

A autorização de administração dependia de exatamente uma coisa: um token cuja claim scope carregasse o escopo de administração. Dois endpoints de administração colocavam esse escopo em um token novo.

POST /api/v1/token, o endpoint de impersonação que as ferramentas de suporte usam, quebrava o parâmetro de query scopes e copiava o resultado literalmente para um access token e um refresh token, sem nenhuma conferência contra os AllowedScopes registrados do client. Uma credencial de administração, por mais efêmera que fosse, portanto emitia um refresh token de administração de vida longa para qualquer usuário e qualquer client: privilégio que sobrevive à rotação da credencial com a qual você estava preocupado. A criação de client, por sua vez, fazia bind de um objeto client cru direto do corpo da requisição, ClientSecretHashes incluído, então um admin podia registrar um client client_credentials com o escopo de administração e um hash de segredo que ele já conhecia. List e get devolviam todos os hashes armazenados.

O endpoint de impersonação agora recusa o escopo de administração e limita o resto pelos AllowedScopes do próprio client, create e update fazem bind de um DTO sem campo de hash de segredo, e nenhuma resposta carrega um hash. Uma permissão que consegue emitir a si mesma não é uma permissão.

Um hífen, um grupo sem dono e um email fazendo o trabalho de uma identidade

O segundo hábito é deixar que algo com um nome dentro faça as vezes de uma identidade.

O email era a chave de junção do login federado: global, presente em muitos provedores, e igual ao que a conexão que faz a afirmação disser que ele é. A correção foi resolver pelo par de provedor e subject, que nenhuma outra conexão consegue cunhar.

No control plane, o nome era o slug do tenant. O roteador que transforma um slug em prefixo de tabela de armazenamento removia hífens, então acme-corp e acmecorp produziam o mesmo prefixo, o que significava o mesmo conjunto de tabelas e a mesma chave de assinatura no Vault. Dois tenants registráveis separadamente compartilhando um conjunto de tabelas e uma chave de assinatura. Registre o segundo e você está olhando para os usuários do primeiro. A correção não foi uma função de prefixo mais esperta. Foi proibir hífens nos slugs para que o mapeamento seja injetivo, e isso merece a versão longa.

SCIM foi o que doeu, porque metade dele estava certa. Toda leitura e escrita de usuário reconferia que o recurso tinha sido provisionado pelo client que chamava. Os grupos, no arquivo ao lado, não tinham dono nenhum: create nunca carimbava um, list passava null como filtro de dono e devolvia todos os grupos do ambiente, e get, replace, patch e delete aceitavam qualquer id. Conhecer o id de um grupo era a autorização inteira, e onde vários clients de provisionamento compartilham um ambiente isso é leitura e escrita entre clients da participação em grupos, que é como papéis são concedidos. Os grupos agora carregam o client que os criou, e toda leitura e escrita tem escopo nele.

O terceiro hábito é fazer a coisa irreversível antes de rodar a verificação.

O caso mais claro é um recurso que clientes compram: um tenant registra um webhook que recebe a pergunta "permitir este login?" no momento da autenticação, e pode configurá-lo para impor a resposta. O hook rodava em todos os caminhos que estabelecem sessão, corretamente. Só que rodava depois do SignInAsync. Então uma rejeição lançava exceção, a requisição voltava 500, e o navegador já estava com um cookie de sessão assinado na mão. Qualquer client que ignorasse o erro estava logado, bloqueado ou não. O webhook que bloqueia logins não bloqueava.

A correção é uma linha movida, seis vezes: rodar o hook antes do SignInAsync em senha, OIDC, SAML, verificação de MFA e configuração de MFA, e antes de cunhar tokens no endpoint de token. A mudança na biblioteca e a mudança no control plane entraram com dois minutos de diferença. Uma rejeição agora é um 403 limpo sem Set-Cookie, e um teste ponta a ponta garante que um login negado não emite nenhum .AspNetCore.Cookies.

A mesma ordenação apareceu em três lugares mais silenciosos. Códigos de autorização eram lidos e depois apagados como duas operações, então dois resgates simultâneos de um mesmo código podiam ambos passar pela leitura e ambos receber tokens; o grant store agora expõe TryConsumeAsync, um delete condicional que retorna true apenas para quem de fato removeu a linha. O restore fazia upsert das entidades antes de verificar qualquer coisa: o campo FileHashes estava documentado como verificado durante o restore, mas nunca era preenchido nem conferido, o que transforma um backup adulterado em uma primitiva de escrita contra a produção. E o webhook da Stripe agia sobre os eventos antes de registrar que os tinha visto, então uma reentrega at-least-once podia oscilar o plano e o status de um tenant ou reenviar cobranças. Os três conferem primeiro agora.

Validar a URL quando ela é salva não prova nada na hora em que ela é discada

Clientes nos entregam URLs: webhooks de autenticação, destinos de backup, callbacks de retenção. Rejeitar uma que resolve para um endereço privado no momento em que é salva é a proteção óbvia, e sozinha ela é quase inútil, porque DNS não é uma promessa. Um nome que resolve publicamente quando você o valida pode resolver para 169.254.169.254 quando você conecta.

Por isso a verificação que sustenta tudo não mora no handler de configurações. Ela é um connect callback no próprio handler HTTP. Toda chamada de saída para uma URL fornecida pelo cliente resolve o host, recusa loopback, RFC1918, unique-local e toda a faixa link-local 169.254.0.0/16 onde vive o serviço de metadados da nuvem, e então abre o socket exatamente para aquele endereço validado em vez de para o hostname, de modo que não sobra uma segunda resolução para fazer rebind. Redirecionamentos automáticos estão desligados, porque um 302 é mais uma chance de apontar para um alvo interno. A verificação no momento de salvar continua como mensagem de erro para o cliente, que é para o que ela sempre serviu.

Dois endpoints e um arquivo de backup que só a rede protegia

/_internal/cluster/gossip estava mapeado como anônimo, e sua verificação de segredo compartilhado existia como comentário, não como código. /_internal/backchannel-logout estava mapeado como anônimo com antiforgery desativado no listener público, aceitava um corpo {"SubjectId": "..."} e revogava todos os grants daquele subject. Nenhum dos dois tinha qualquer proteção dentro do processo. A única coisa na frente deles era a configuração de ingress e, dado o achado sobre headers encaminhados acima, qualquer defesa baseada em IP era falsificável de qualquer jeito. Os dois agora exigem um segredo compartilhado comparado em tempo constante, ou um endereço de origem interno quando nenhum segredo está configurado.

Os backups tinham a mesma característica. Em hosts que usam a fonte de chaves local, a tabela de chaves de assinatura guarda o escalar privado EC, e o backup serializava cada coluna literalmente em JSONL gzipado em texto claro. Quem conseguisse ler um arquivo de backup conseguia forjar tokens para o issuer de onde ele veio. Essa tabela agora fica excluída por padrão, com uma adesão explícita para destinos criptografados, e todo arquivo de dados tem hash SHA-256 gravado no manifesto e verificado antes de o restore escrever uma única linha.

Onde uma verificação roda é a maior parte do que ela vale

Nenhuma dessas era uma verificação ausente no sentido de ninguém ter pensado nela. MFA estava implementado e testado. O hook do webhook rodava em todos os caminhos. A validação de SSRF existia. Os usuários SCIM tinham escopo correto em todo verbo, no arquivo ao lado daquele em que os grupos não tinham. Em quase todos os casos a lógica estava escrita, correta e instalada em algum lugar onde não conseguia fazer o seu trabalho: sobre um valor fornecido por quem chama, em uma etapa que quem chama podia pular, depois do estado que ela deveria impedir, ou sobre o recurso vizinho.

Então a pergunta útil em cada endpoint não é "a gente valida isso?". São outras três. O que esta requisição de fato prova, em oposição a afirmar? Este identificador é algo que quem chama consegue nomear, ou algo que só quem chama de direito consegue possuir? E acontece alguma coisa irreversível antes de a resposta chegar? Uma revisão escrita, trabalhada seção por seção com uma severidade em cada achado, é principalmente um dispositivo para forçar essas três perguntas através do código de que você tem certeza. O nosso era certeza. Eram dezesseis seções de certeza, mais oito coisas que teriam virado o incidente de alguém.

Nada disso estava ao alcance de um cliente, porque ainda não havia clientes. Esse é o argumento para fazer isso antes do lançamento, e não depois do primeiro relatório: a mesma lista, escrita seis meses mais tarde, é um cronograma de divulgação em vez do trabalho de uma noite. Se você quer a versão atual das respostas, nossa página de segurança explica como os tenants são isolados, o que é criptografado em repouso e o que um backup dos seus dados realmente contém.