Um provedor de identidade nos disse quem você era, e nós acreditamos
O login único tem uma premissa silenciosa: quando um usuário chega de um provedor de identidade, esse provedor já respondeu por ele, então você pode pular a senha e simplesmente deixá-lo entrar. O ponto inteiro é confiar no IdP. O bug que estamos prestes a descrever vivia na lacuna entre "confiar que o IdP autentica os seus próprios usuários" e "confiar em qualquer coisa que o IdP lhe diga sobre quem são os seus usuários". Não são a mesma frase, e a diferença foi uma tomada de conta.
Eis o cenário. Um inquilino na nossa plataforma pode configurar conexões federadas: SAML para o seu IdP corporativo, OIDC para outro. Um usuário faz login por meio de uma dessas conexões, o IdP devolve uma asserção, e o nosso servidor precisa responder a uma pergunta: que conta local é esta? Erre esse mapeamento e você terá ou um estranho trancado para fora da própria conta ou, muito pior, um estranho entrando na conta de outra pessoa.
A chave de junção era um e-mail, e um e-mail é apenas uma afirmação
O nosso código respondia "que conta é esta" da maneira óbvia. A asserção carregava um e-mail, então procurávamos o usuário por ele: FindByEmailAsync(assertedEmail). Se existisse uma conta correspondente, o usuário federado recorrente era resolvido sobre ela e tinha a sessão iniciada. Limpo, simples, e exatamente como muitas integrações de SSO são escritas.
O problema é o que esse e-mail de fato é. Não é um fato que o IdP provou. É uma cadeia de caracteres dentro de uma asserção, e a asserção só é tão confiável quanto a conexão pela qual ela veio. Em um mundo multi-inquilino você não controla todas as conexões. Qualquer um que consiga montar uma (o seu próprio IdP SAML, o seu próprio provedor OIDC) pode colocar a cadeia que quiser no campo de e-mail. Assim, uma conexão na qual você não confia afirma email = [email protected], a nossa busca encontra a conta real do CEO, e o atacante fica com a sessão iniciada como ele. Sem senha, sem phishing, sem falha na criptografia. A assinatura na asserção era perfeitamente válida. Ela apenas respondia por uma afirmação que jamais deveríamos ter tratado como identidade.
A parte perturbadora é que cada peça individual funcionava. O IdP autenticou o seu próprio usuário corretamente. A assinatura foi verificada. A conta existia. A tomada não foi a falha de nenhuma verificação; foi usar o campo errado como chave.
Por que mais validação não é a correção
A reação tentadora é validar o e-mail com mais rigor. Exigir email_verified. Verificar o domínio. Adicionar uma regra. Mas repare no formato da armadilha: o atacante controla a asserção, então qualquer propriedade que você leia da asserção é uma propriedade que o atacante pode definir. Exigir email_verified = true de uma conexão que o atacante possui é pôr a raposa para cuidar do galinheiro. Você não consegue validar a sua saída de confiar na fonte errada.
O e-mail está bem como conveniência. É uma chave primária terrível, porque é global (o mesmo endereço pode aparecer em muitos provedores) e falsificável (é o que quer que a conexão que o afirma diga). Uma chave de junção não pode ser nenhuma das duas coisas.
A correção foi mudar a chave, não adicionar verificações
A verdadeira correção foi parar de juntar pelo e-mail por completo e juntar pela única coisa que uma conexão não consegue falsificar para um provedor que ela não possui: o par (provider, sub). O sub é o identificador de sujeito que o provedor emite para aquele usuário, restrito àquele provedor. Um usuário recorrente é resolvido procurando a identidade local previamente vinculada ao (provider, sub) desta conexão. A conexão de um atacante tem o seu próprio id de provedor; ela pode cunhar qualquer sub que quiser dentro do seu próprio espaço de nomes, mas não consegue produzir o (provider, sub) da conexão de outra pessoa. O espaço de nomes é o fosso.
O e-mail então cai para o papel que sempre deveria ter tido: uma dica para vincular, nunca para resolver, e somente quando há quem responda por ele. Só fazemos um e-mail afirmado corresponder a uma conta existente quando o e-mail é verificado por uma fonte na qual realmente confiamos para aquele domínio: email_verified proveniente de uma conexão cujo domínio consta nos AllowedDomains do inquilino. Fora disso, uma nova identidade federada ganha a sua própria conta, não a de outra pessoa.
A lição, fatorada
Quando você federa identidade, separe duas perguntas que você é tentado a fundir. "Este provedor autenticou este usuário?" é respondida pela assinatura. "Quem é este usuário no meu sistema?" tem de ser respondida por uma chave que a parte que afirma não consiga falsificar através de fronteiras, o que significa um subject próprio do provedor, e não um atributo global como o e-mail. Trate cada campo de uma asserção como controlado pelo atacante, a menos que a fonte específica daquele campo seja uma na qual você confia para aquela afirmação específica. O e-mail é o campo para o qual as pessoas correm primeiro porque é humano e parece único. É exatamente o errado.
Lançamos isto como parte de uma passagem de segurança pré-lançamento sobre o nosso próprio servidor de autenticação, antes que alguém nos confiasse os seus logins. É o tipo de bug que passa em todos os testes, verifica todas as assinaturas, e entrega as chaves a quem quer que peça no formato certo. A correção não foi uma fechadura melhor. Foi perceber que estávamos lendo a linha errada do documento de identidade.
Federar identidade com segurança se resume a juntar por uma chave que a parte que afirma não consegue falsificar, e errar nisso é uma tomada de controle de conta que passa em todos os testes. Authagonal resolve os usuários federados pelo seu subject por provedor, nunca pelo e-mail contido na asserção.