Fluxos de Redefinição de Senha: Por Que um uuid4 em uma Linha de Banco É uma Senha Que Nunca Expira
Django Security Series — Post 14 | Série III: Autenticação e Sessão
OWASP A07:2025 — Authentication Failures | Tempo de leitura: ~23 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um fluxo de redefinição feito à mão ao lado do fluxo do Django, e uma vítima cuja senha é deliberadamente forte — para que adivinhar não seja o caminho. O laboratório planta um token de redefinição emitido há 400 dias, do jeito que um link sobrevive em um log de proxy. Reenvie-o contra o endpoint vulnerável e você é dono da conta; reenvie-o contra o seguro e recebe um 400. Depois rode o Bandit e o Semgrep sobre as duas views e conte quantos achados conseguem diferenciá-las.
A redefinição de senha é o sistema de autenticação que você não projetou. Você passou semanas no login — throttling (Post 11), rotação de sessão (Post 12), uma política de senhas para a qual você de fato leu a norma (Post 13) — e então, em uma tarde, construiu uma segunda porta que emite credenciais por e-mail, e a construiu com uma string aleatória e uma linha de banco.
Fui olhar o fluxo de redefinição do Petição Brasil antes de escrever este post, esperando encontrar algo a confessar. Uma conta ali é uma identidade de publicação: ela cria e publica petições, e não as assina — a assinatura passa pela PKI do gov.br, e é o certificado que torna uma assinatura vinculante, não a sessão que chegou ao formulário. Isso limita o estrago, mas não o torna irrelevante: quem conclui uma redefinição pode publicar em seu nome, e o fluxo de redefinição é o caminho mais curto para tomar a conta. O que encontrei foi, em boa parte, tranquilizador: os tokens vêm do gerador do próprio Django, e o endpoint de solicitação se recusa a dizer se um endereço está cadastrado. A pergunta que ninguém tinha feito era para onde o link aponta. Um link de redefinição precisa nomear um domínio, e, quando rastreei este, o domínio vinha da requisição, do cabeçalho Host que o cliente enviou. A única coisa que impede um cabeçalho forjado de reescrever esse link é o ALLOWED_HOSTS, e o valor dele mora em uma variável de ambiente no servidor, não no código. Basta uma mudança descuidada nessa variável, um * posto para depurar um deploy, para o e-mail de redefinição passar a apontar para onde o atacante quiser, sem nenhuma mudança de código que uma revisão ou um teste pudesse pegar.
Esse é o formato deste post. Um fluxo de redefinição tem quatro formas amplamente independentes de falhar, e uma base de código pode acertar três delas enquanto a quarta entrega contas silenciosamente. Duas moram no token: ele fica válido tempo demais, e continua valendo depois de usado. Uma terceira mora no link, cujo host é o atacante quem fornece. A última não é bem sobre credenciais. É sobre o endpoint responder a uma pergunta que ninguém fez, e contar quem tem conta aqui.
A observação que unifica tudo, e a que levei mais tempo para enxergar, é que nenhuma delas é um recurso de segurança ausente. Cada uma é uma pergunta que ninguém lembrou de fazer sobre um valor que já estava ali, à mão.
O Ataque: O Que É e Como Funciona
Comece pela propriedade que torna esta classe diferente de todo o resto da Série III: um token de redefinição é uma credencial que sua aplicação envia por e-mail a um terceiro, por um canal não autenticado, e depois aceita como prova de identidade. Credenciais de login são escolhidas pelo usuário e nunca transmitidas por você. Cookies de sessão são seus, definidos no próprio navegador do usuário, e expiram. Um token de redefinição é uma credencial ao portador que você distribui sob demanda, para quem souber citar um endereço de e-mail, e que autentica quem quer que a apresente. É a única credencial do sistema com essas três propriedades ao mesmo tempo.
Então as perguntas interessantes são todas sobre o tempo de vida dessa credencial ao portador, e a implementação ingênua não responde a nenhuma. A implementação ingênua é ingênua de um jeito específico e reconhecível: gerar um uuid4, salvá-lo em uma linha associada ao usuário, enviar por e-mail um link com ele e, quando o link voltar, buscar a linha e trocar a senha. Cada passo disso é defensável. uuid4 são 122 bits de entropia vindos do gerador de números pseudoaleatórios criptograficamente seguro (CSPRNG) do sistema operacional — ninguém vai adivinhar, e comprimento nunca foi o problema aqui. Guardar no servidor permite revogar. Vincular a um usuário impede que o token de uma pessoa redefina a senha de outra.
O que a linha não sabe é qualquer coisa sobre tempo. E o curioso é que, neste desenho, o schema sabe. Existe uma coluna created, porque alguém pensou em expiração. Existe um timestamp used_at, porque alguém pensou em reuso. As duas colunas são escritas. Nenhuma é lida por uma verificação que recuse alguma coisa. O bug não é um campo faltando; é um if faltando, em uma função que já tem o dado em mãos. É por isso que ele sobrevive à revisão de código — quem olha o model vê uma tabela bem projetada. Essas duas colunas não lidas são as duas primeiras falhas: o token nunca expira e pode ser usado mais de uma vez. As outras duas estão em como o link é montado e em como o endpoint de solicitação responde.
A primeira falha é que o token nunca expira, porque nada lê created. A consequência não é que o atacante tenha mais tempo para adivinhar. Ele nunca ia adivinhar. A consequência é que um link que vazou continua vivo. E links de redefinição vazam o tempo todo, porque são URLs comuns e a web é extremamente boa em copiar URLs comuns para lugares que ninguém audita. Eles ficam em logs de acesso de proxy reverso, que muitas vezes são enviados a um agregador de logs com retenção de dois anos e uma lista de acesso bem mais ampla que a do banco. Ficam no histórico do navegador, que em uma máquina compartilhada ou corporativa não é privado. Passam por middleboxes de inspeção TLS em redes corporativas, que é exatamente o cenário em que a tranquilidade do "criptografado em trânsito" deixa de valer. Ficam em backups de caixa postal que sobrevivem ao funcionário. E se a página de confirmação carregar qualquer recurso de terceiro — um script de analytics, uma fonte web, uma imagem incorporada — navegadores antigos colocavam a URL completa, token incluído, no cabeçalho Referer enviado a esse terceiro. Navegadores modernos usam strict-origin-when-cross-origin por padrão, que remove o caminho, e o Django fecha isso duas vezes: o SecurityMiddleware envia Referrer-Policy: same-origin por padrão desde a 3.1, e a PasswordResetConfirmView move o token para a sessão e redireciona para uma URL set-password antes de renderizar qualquer coisa, então o token nunca fica em um Referer. Esse vazamento específico praticamente fechou; os outros não.
A segunda falha é que o token pode ser usado de novo, porque nada lê used_at, e ela agrava a primeira. Um token que nunca é aposentado não é uma credencial de uso único que vazou, é uma senha paralela que o usuário não sabe que existe e não pode trocar, porque trocar a senha não apaga nem invalida o token: ele fica guardado em uma tabela própria do banco, que a troca de senha nem consulta. No laboratório isso é o exploit inteiro: o token plantado foi emitido há 400 dias, o endpoint vulnerável o aceita, e o aceita de novo em seguida com uma senha diferente.
A terceira falha está mais cedo no fluxo, onde o link é montado: o domínio do link vem do atacante. É o chamado envenenamento do cabeçalho Host. O e-mail de redefinição precisa conter uma URL absoluta, e uma URL absoluta precisa de um host. Se o código pedir esse host à requisição — request.build_absolute_uri() ou request.get_host() com um ALLOWED_HOSTS permissivo, ou request.META['HTTP_HOST'] em qualquer configuração — então o domínio em um e-mail que seu servidor envia para a caixa postal de uma vítima é um valor que o atacante colocou em um cabeçalho. O atacante envia o endereço da vítima ao formulário de redefinição com Host: evil.test, a vítima recebe um e-mail de redefinição genuíno, de um remetente genuíno, clica no link porque de fato acabou de clicar em "esqueci minha senha", e o navegador dela entrega o token ao servidor do atacante. Não há página de phishing a detectar nem domínio sósia no remetente; a única mentira está em um link, dentro de uma mensagem que o sistema real realmente enviou. Algumas variantes nem precisam da ação da vítima: um gateway de e-mail ou antivírus que pré-carrega links nas mensagens recebidas segue o link envenenado por conta própria e entrega o token sem que ninguém clique.
A quarta falha é que o endpoint de solicitação revela quem tem conta, a chamada enumeração de contas. É a menor das quatro. "Nenhuma conta com esse e-mail" e "Confira sua caixa de entrada" são respostas diferentes, e um formulário que dá respostas diferentes vira um oráculo: no jargão de segurança, algo que o atacante pode consultar quantas vezes quiser para obter um sim ou não sobre um segredo, aqui se um e-mail tem conta. Alimente-o com um corpus de endereços vazados e você descobre quais deles têm conta no seu site — o que é uma lista de clientes e, dependendo do site, uma lista muito sensível. Um endpoint de redefinição em um serviço de saúde mental, uma plataforma de relacionamentos ou um site de petições políticas vaza algo bem mais consequente que um nome de usuário. Esta é a falha que mais gera objeção, normalmente com "o formulário de cadastro já vaza isso" — o que muitas vezes é verdade e é um argumento para consertar o cadastro, não para manter um segundo oráculo.
Qualquer uma das três primeiras falhas basta sozinha, e nenhuma delas precisa de adivinhação de senha, que é o que separa este post dos Posts 11 e 13. Não há wordlist, não há limite de taxa a vencer, e não é preciso uma senha fraca. A senha da conta pode ser uma frase de 30 caracteres vinda de um gerenciador; é irrelevante, porque o atacante não vai adivinhá-la. Ele vai substituí-la.
Incidentes Reais
GitLab CVE-2023-7028 — tomada de conta não autenticada via redefinição de senha, CVSS 10.0 (corrigida em 11 de janeiro de 2024; incluída no catálogo Known Exploited Vulnerabilities da CISA em 1º de maio de 2024)
No GitLab 16.1.0 entrou um recurso que soa inofensivo: permitir que usuários recebam e-mails de redefinição de senha em um endereço secundário. A implementação aceitava a lista de destinatários vinda da própria requisição de redefinição e deixava de verificar se aqueles endereços pertenciam mesmo à conta. O resultado é que um atacante não autenticado podia enviar uma solicitação de redefinição nomeando o e-mail da vítima e o dele próprio, e o GitLab enviava um token válido para os dois.
A pontuação merece leitura, não apenas uma olhada. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H dá um 10.0 cravado: atacável pela rede, baixa complexidade, sem privilégios exigidos e (a parte que importa aqui) UI:N, sem interação do usuário. Diferente da variante de envenenamento de host acima, a vítima não precisa clicar em nada. O atacante pede a redefinição, recebe o token, define uma nova senha e fica com a conta. O GitLab classificou o escopo como alterado (S:C), o que reflete o que uma conta do GitLab costuma guardar: código-fonte, credenciais de CI/CD, chaves de deploy, material de assinatura. Os analistas do NVD discordaram nesse ponto e deram 9.8, com escopo inalterado. Tomada de conta em uma plataforma de controle de versão é um evento de cadeia de suprimentos, que é onde um post mais adiante na série retoma isso.
As versões afetadas iam da 16.1 até a 16.7.1; o GitLab publicou correções nas 16.7.2, 16.6.4 e 16.5.6 em 11 de janeiro de 2024, com backports para 16.1.6, 16.2.9, 16.3.7 e 16.4.5 — um leque de backports que diz o quanto levaram a sério. Contas com autenticação de dois fatores foram apenas parcialmente poupadas: o próprio comunicado do GitLab observa que o atacante ainda conseguiria redefinir a senha, mas não superaria o segundo fator. É uma declaração precisa e ligeiramente desconfortável do que o MFA garante aqui, e é o argumento do próximo post.
O MITRE ATT&CK mapeia o passo operativo como T1098 — Account Manipulation: o adversário modifica as credenciais associadas a uma conta que não é dele, depois do que o acesso segue como T1078 — Valid Accounts e é, do ponto de vista da aplicação, indistinguível do usuário real fazendo login. Quero sinalizar que o encaixe é imperfeito, porque a imperfeição é informativa. O ATT&CK arquiva T1098 sob Persistence e Privilege Escalation — o modelo do framework é um adversário que já está dentro e manipula contas para permanecer. Uma falha de fluxo de redefinição usa a mesma técnica para acesso inicial, e esse descompasso é exatamente o que torna esta classe difícil de detectar operacionalmente: os eventos visíveis são uma troca de senha e um login bem-sucedido, os mesmos que produz um usuário que de fato esqueceu a senha.
A leitura regulatória a partir do Brasil é a que eu sempre retomo, porque a pergunta jurídica não é "você foi violado" e sim "suas medidas eram aptas". O Art. 46 da LGPD exige que agentes de tratamento adotem medidas de segurança "aptas a proteger os dados pessoais de acessos não autorizados", e o Art. 44, III mede isso contra "as técnicas de tratamento de dados pessoais disponíveis à época". Nesta classe a medida disponível não é meramente disponível, é o padrão do framework: o PasswordResetTokenGenerator do Django entrega um token com prazo e autoinvalidação desde antes de a maioria de nós começar a escrever Django. Optar por fazer à mão uma linha com uuid4 é uma decisão que um controlador precisa estar preparado para justificar, e "não pensamos nisso" não é justificativa, é o achado. Note também de quem é o dever. Na LGPD, a obrigação de comunicação de incidente do Art. 48 recai sobre o controlador — quem decide as finalidades do tratamento — e não sobre quem operou o servidor. Se você constrói a aplicação e outra pessoa a opera, o fluxo de redefinição que você entregou continua sendo exposição dela, e os advogados dela vão ler o seu código.
Fontes: GitLab — Critical Security Release: 16.7.2, 16.6.4, 16.5.6 (11 de janeiro de 2024) · CISA — Adds One Known Exploited Vulnerability to Catalog (1º de maio de 2024)
Proteções Padrão do Django
A resposta do Django ao problema do token é uma decisão de projeto, não uma string aleatória mais longa, e vale ler o código de verdade, porque todo o comportamento decorre de cinco valores concatenados. Este é o PasswordResetTokenGenerator._make_hash_value no 5.2:
# django/contrib/auth/tokens.py
def _make_hash_value(self, user, timestamp):
login_timestamp = (
""
if user.last_login is None
else user.last_login.replace(microsecond=0, tzinfo=None)
)
email_field = user.get_email_field_name()
email = getattr(user, email_field, "") or ""
return f"{user.pk}{user.password}{login_timestamp}{timestamp}{email}"
Essa string passa por salted_hmac() com a sua SECRET_KEY, e o token que você vê na URL é <timestamp-em-base36>-<hmac>. O gerador tem dois métodos: make_token(user) monta essa string quando o e-mail sai, e check_token(user, token) a recalcula a partir do estado atual do usuário quando o link volta, e só aceita o link se as duas baterem e o timestamp ainda estiver dentro do prazo. Nada é armazenado. Não há tabela de tokens, nem job de limpeza, nem flag used — e esse é o ponto, porque cada uma das verificações que a versão feita à mão esquece é consequência do que entrou no hash.
A propriedade que mais admiro é o uso único, porque é justamente a verificação que fluxos feitos à mão esquecem, e aqui ninguém precisa escrevê-la. Funciona assim: o token é calculado a partir do hash da senha atual do usuário, o user.password. Quando a redefinição conclui, a senha muda e, com ela, o hash. Como o Django acrescenta um salt aleatório a cada hash, isso vale até se o usuário escolher a mesma senha: o hash gravado sai diferente mesmo assim. A partir desse instante, recalcular o token com o hash novo dá outro resultado, e nenhum token emitido antes bate mais, inclusive o que o usuário acabou de usar. Não existe um flag de "já usado" para marcar nem um if para esquecer: o token deixa de valer porque o dado a partir do qual ele foi calculado mudou.
A expiração cai do mesmo jeito. O timestamp entra no hash e também viaja em claro no início do token, então o check_token o compara com PASSWORD_RESET_TIMEOUT sem tocar no banco. O last_login traz uma terceira propriedade que ninguém pediu: um usuário que solicita a redefinição, depois lembra a senha e entra normalmente, matou silenciosamente o link parado na caixa de entrada. Desde a 3.2 o endereço de e-mail também está no hash, então trocar o endereço mata todo link já enviado ao antigo, o que importa quando aquela caixa não é mais do usuário.
Esse timeout padrão é de 259200 segundos, três dias, e isso é longo demais para uma credencial ao portador: durante três dias, uma cópia vazada de um link que o usuário ainda não usou, encontrada em um log, no histórico do navegador ou em um backup de e-mail, basta para tomar a conta. Reduza-o com PASSWORD_RESET_TIMEOUT = 3600, uma hora. O custo é pequeno: quem abrir o e-mail depois disso só precisa pedir um link novo.
Dois detalhes no check_token recompensam uma segunda olhada:
for secret in [self.secret, *self.secret_fallbacks]:
if constant_time_compare(
self._make_token_with_timestamp(user, ts, secret),
token,
):
break
else:
return False
A comparação usa constant_time_compare, e não ==, por um motivo. O == para no primeiro caractere diferente, então um token forjado com o começo certo demora um pouco mais para ser rejeitado do que um errado desde o início. Um atacante que consiga medir essa diferença pode descobrir um token válido caractere por caractere. O constant_time_compare leva o mesmo tempo qualquer que seja a entrada, então o tempo de resposta não revela nada.
O laço sobre secret_fallbacks existe para a rotação de chaves. Todo token é assinado com a SECRET_KEY, então trocar a chave quebraria na hora todos os links de redefinição que já estão na caixa de entrada de alguém. O SECRET_KEY_FALLBACKS permite listar as chaves antigas: o check_token tenta primeiro a chave atual e depois cada uma das antigas, de modo que links assinados antes da troca continuam valendo até você tirar a chave antiga da lista.
Esse laço também deixa a dependência explícita: seus tokens de redefinição só são tão secretos quanto a SECRET_KEY. É a chave que impede qualquer outra pessoa de calcular o HMAC, então um atacante que a obtenha, e que também consiga ler a linha do usuário em um dump ou backup do banco, pode gerar links de redefinição válidos para qualquer conta. Um post mais adiante na série trata da SECRET_KEY por inteiro; o SIGNING_KEY do simplejwt usa a mesma chave por padrão, então um único vazamento expõe os dois.
O problema do host tem uma resposta separada e mais antiga. A documentação de segurança do Django diz diretamente:
Django uses the
Hostheader provided by the client to construct URLs in certain cases. While these values are sanitized to prevent Cross Site Scripting attacks, a fakeHostvalue can be used for Cross-Site Request Forgery, cache poisoning attacks, and poisoning links in emails.
É por isso que o ALLOWED_HOSTS existe. Ele chegou no Django 1.3.6, em 19 de fevereiro de 2013, e a nota de versão é incomumente franca sobre o motivo: o projeto vinha documentando como configurar seu servidor web para rejeitar cabeçalhos Host ruins, e então descobriu que "even with the recommended web server configurations there are still techniques available for tricking many common web servers into supplying the application with an incorrect and possibly malicious Host header" (em tradução livre: mesmo com as configurações de servidor web recomendadas, ainda havia técnicas para enganar muitos servidores web comuns e fazê-los entregar à aplicação um cabeçalho Host incorreto e possivelmente malicioso). Então a validação migrou para dentro do framework, embora a 1.3.6 a tenha entregado desligada, com padrão ['*'] por compatibilidade. É também a má configuração que o teste de host mais abaixo simula.
E aí vem a frase que deveria fazer você ir dar um grep na sua base de código:
This validation only applies via
get_host(); if your code accesses theHostheader directly fromrequest.METAyou are bypassing this security protection.
Em tradução livre: essa validação só se aplica via get_host(); se o seu código lê o cabeçalho Host direto de request.META, está contornando essa proteção de segurança. Na prática, request.get_host() e request.META['HTTP_HOST'] leem o mesmo cabeçalho, mas só o primeiro o confere. O get_host() compara o valor com o ALLOWED_HOSTS e, se ele não estiver na lista, levanta um erro e a requisição termina em 400. O request.META['HTTP_HOST'] devolve o texto cru que o cliente mandou, sem verificação nenhuma. Um link montado a partir dele ignora o ALLOWED_HOSTS por completo, por mais correta que seja a configuração. Por isso vale o grep: procure HTTP_HOST no seu código e troque cada leitura direta por request.get_host() ou, melhor ainda, por um domínio vindo da configuração.
Por fim, o fluxo que o Django já entrega pronto. Ele tem três peças, uma para cada etapa da redefinição. Primeiro, a PasswordResetView mostra o formulário em que o usuário digita o e-mail. Em seguida, o PasswordResetForm procura a conta e envia o e-mail com o link. Por último, quando o usuário clica, a PasswordResetConfirmView recebe o link, confere o token e deixa o usuário escolher a nova senha.
Usadas juntas, essas peças dão conta de três das quatro falhas sem nenhum código seu. As duas primeiras, o token que não expira e o token reutilizável, desaparecem porque o link carrega um token do gerador, que expira e vale uma vez só. A quarta, a enumeração, fica quase fechada: a tela de resposta é a mesma exista o endereço ou não, embora reste uma diferença de tempo, como mostra a seção Implementação Segura. De quebra, o form só envia e-mail a usuários ativos e com senha utilizável, então uma conta que só entra por SSO, sem senha própria, não recebe link.
A terceira falha, o domínio do link, continua aberta. Para montar o link, o form precisa de um domínio e o procura em dois lugares, nesta ordem: no framework de sites do Django (django.contrib.sites), se ele estiver instalado; se não estiver, em request.get_host(), ou seja, no cabeçalho Host da requisição, protegido apenas pelo ALLOWED_HOSTS. O PasswordResetForm.save() até aceita um argumento, domain_override, que fixa o domínio, mas a PasswordResetView nunca o passa. O jeito mais simples de fechar essa brecha é dar ao Django um domínio fixo pelo framework de sites:
# settings.py
INSTALLED_APPS += ["django.contrib.sites"]
SITE_ID = 1
O SITE_ID aponta para uma linha da tabela de sites, e o Django cria essa linha com o domínio example.com. Troque-o pelo seu domínio real, no admin ou em uma data migration. A partir daí, o domínio do link sai dessa tabela, e não da requisição, e um cabeçalho Host forjado deixa de ter efeito sobre ele. A alternativa, se você não quiser o framework de sites, é uma subclasse da PasswordResetView cujo form_valid() passe domain_override ao form.save(). Essa sobrescrita precisa substituir o form_valid() da classe-mãe em vez de chamá-lo, porque a classe-mãe chama form.save() de novo, e esse segundo e-mail sairia com o host da requisição. Feito isso, quase não sobra código a escrever para o tema deste post.
Padrão Vulnerável: O Que NÃO Fazer
Aqui está o fluxo feito à mão, com as partes que importam marcadas. Não é uma caricatura: olhado um passo de cada vez, tudo nele é defensável, e é por isso que ele passa por uma revisão de código. É uma versão enxuta do que o laboratório entrega como views_vulnerable.py.
# INSEGURO — não use em produção
import uuid
def request_reset(request):
email = request.POST.get("email", "")
try:
user = User.objects.get(email__iexact=email)
except User.DoesNotExist:
# FALHA 4 — a resposta difere, então este endpoint é um oráculo de cadastro.
return HttpResponse("no account with that email", status=404)
row = ResetToken.objects.create(user=user, token=str(uuid.uuid4()))
path = reverse("confirm", kwargs={"uidb64": ..., "token": row.token})
# FALHA 3 — o host desta URL é o que o cabeçalho Host do cliente disser.
link = request.build_absolute_uri(path)
send_mail("Reset your password", f"Reset here: {link}", FROM, [user.email])
return HttpResponse("reset email sent")
Repare que o model por trás está correto:
class ResetToken(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=36, unique=True)
created = models.DateTimeField(default=timezone.now) # nunca lido
used_at = models.DateTimeField(null=True, blank=True) # nunca lido
unique=True, uma chave estrangeira, um timestamp de criação, um timestamp de uso. Uma revisão de schema aprova isso sem comentários. Agora a metade que decide tudo:
# INSEGURO — não use em produção
def confirm(request, uidb64, token):
user = user_from_uidb64(uidb64)
row = ResetToken.objects.filter(token=token).first()
if user is None or row is None or row.user_id != user.pk:
return HttpResponse("invalid reset link", status=400)
# FALHA 1 — nada lê row.created. O token nunca expira.
# FALHA 2 — nada lê row.used_at. O token é infinitamente reutilizável.
user.set_password(request.POST["password"])
user.save()
row.used_at = timezone.now() # escrito aqui, e lido em lugar nenhum
row.save(update_fields=["used_at"])
return HttpResponse("password updated")
A linha row.used_at = timezone.now() é a que acho realmente instrutiva. Alguém a escreveu. Escreveu porque estava pensando em uso único — ninguém adiciona essa coluna por acaso. Depois, o if que deveria lê-la ficou para depois, e o depois não veio, e o que entrou em produção é um fluxo que cuidadosamente registra a evidência do próprio bug.
Mais uma variante, porque é a versão que sobrevive a uma correção parcial. Um time recebe um achado de pentest sobre expiração e corrige:
# AINDA INSEGURO
if timezone.now() - row.created > timedelta(hours=1):
return HttpResponse("expired", status=400)
A expiração agora é aplicada e o relatório é fechado. O token continua reutilizável por uma hora, continua válido depois que a senha muda, e o link continua sendo construído a partir do host da requisição. Corrigir uma de quatro falhas independentes deixa as outras três no lugar.
Implementação Segura: O Jeito Django
A correção tem duas metades, uma para cada view do fluxo. A view de confirmação, que recebe o link, fecha as duas primeiras falhas. Ela fica mais curta que a versão quebrada, porque a verificação inteira cabe em uma chamada:
# SEGURO — padrão recomendado
from django.contrib.auth.tokens import default_token_generator
def confirm(request, uidb64, token):
user = user_from_uidb64(uidb64)
# Uma chamada: assinatura, idade, uso anterior e um login no meio do caminho.
if user is None or not default_token_generator.check_token(user, token):
return HttpResponse("invalid or expired reset link", status=400)
user.set_password(request.POST["password"])
user.save()
# Nenhuma linha a aposentar: o save acima já invalidou este token,
# porque user.password faz parte do que o token leva no hash.
return HttpResponse("password updated")
O check_token recusa um token adulterado, um token mais velho que o PASSWORD_RESET_TIMEOUT e um token já usado, sem nenhum if a mais. Com isso, o model ResetToken deixa de existir: não há created para ler, used_at para marcar nem job de limpeza para manter.
A view de solicitação, que recebe o e-mail e envia o link, fecha as outras duas. Para a terceira falha, o domínio do link passa a vir da configuração, e não da requisição:
# SEGURO — o domínio canônico é algo que você sabe, não algo que te contam
RESET_BASE_URL = "https://app.example.com" # no settings.py, ou o Site do django.contrib.sites
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)
link = RESET_BASE_URL + reverse("confirm", kwargs={"uidb64": uid, "token": token})
Para a quarta, a resposta passa a ser a mesma exista o endereço ou não:
# SEGURO — mesmo corpo, mesmo status, tendo o endereço batido ou não
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user is not None:
send_reset_email(user)
return HttpResponse("Se existir uma conta para esse endereço, um link de redefinição foi enviado.")
Essa última correção tem um limite. Quando o endereço existe, a view envia um e-mail antes de responder, e enviar e-mail é lento; quando não existe, ela responde na hora. Um atacante que meça o tempo de resposta continua conseguindo distinguir os dois casos. O próprio PasswordResetForm do Django tem esse limite, porque também envia o e-mail antes de a view responder. A solução completa é colocar o envio em uma fila de tarefas, para que as duas respostas saiam no mesmo tempo. Vale fazer isso se a sua lista de usuários for sensível por si só, e vale saber disso em qualquer caso, porque uma mensagem uniforme costuma ser apresentada como se tivesse eliminado o oráculo, e não eliminou.
No domínio, as duas defesas se somam. O ALLOWED_HOSTS deve ser uma lista real em todo deploy, porque recusa um Host forjado mesmo quando o código monta o link a partir da requisição. O domínio vindo da configuração protege o link mesmo quando o ALLOWED_HOSTS está errado, então mantenha as duas. No token, prefira a PasswordResetConfirmView a chamar o default_token_generator você mesmo: além de verificar o token, a view o tira da URL antes de mostrar a página, como visto na seção sobre o ataque.
Se já existe uma tabela ResetToken em produção, a migração tem um passo fácil de esquecer: apagar as linhas antigas no mesmo deploy em que o gerador novo entra. A tentação é escrever um trecho de compatibilidade que aceita o token novo e, para quem ainda tem um link antigo, consulta a tabela. Esse trecho mantém válido todo link antigo que já vazou enquanto ele existir, e na prática ele existe até alguém lembrar de removê-lo. Se não for possível apagar as linhas no dia do deploy, dê a esse trecho uma data de remoção e registre-a em um lugar que alguém vá conferir.
A Visão do Analista
Para um analista, o problema é que esta vulnerabilidade não deixa rastro suspeito nos códigos de status. A força bruta do Post 11 gera uma rajada de 401; uma tomada de conta pelo fluxo de redefinição gera um pedido de redefinição, uma troca de senha e um login, exatamente como um usuário legítimo que esqueceu a senha. O sinal mais útil é a origem: uma redefinição concluída de um IP ou país que a conta nunca usou, ou de um IP diferente do que pediu a redefinição, merece alerta, embora gere falsos positivos (o usuário pede no computador e clica no celular) e possa ser disfarçada com uma VPN ou um proxy residencial. Além disso, dá para mirar cada falha: tokens muito antigos em ResetToken (used_at muito depois de created), recusas no logger django.security.DisallowedHost e rajadas de 404 no endpoint de redefinição. Essas buscas costumam surgir só depois de um comunicado, como o do GitLab, e por isso a defesa principal continua sendo a prevenção.
O que isso deixa para você, em termos de gestão de vulnerabilidades, é um achado que você precisa ir procurar em vez de esperar, e uma pergunta específica a fazer a qualquer aplicação herdada: o fluxo de redefinição usa o token do framework ou um armazenado? É uma primeira passada rápida — check_token, ou a ausência dele ao lado de um set_password —, mas não uma separação limpa: código que usa as views do Django não tem nenhum dos dois, e um check_token presente pode não proteger a escrita. O controle compensatório, quando você não pode mexer no código hoje, é MFA nas contas que importam, e o comunicado do GitLab diz com clareza o quanto isso compensa: o atacante ainda redefine a senha, mas não supera o segundo fator. Isso é uma redução genuína de impacto e não uma correção, que é a definição de manual de um controle compensatório e uma coisa útil de saber dizer com precisão a um dono de risco.
Detectando Automaticamente
Testando Sua Defesa
Cinco testes cobrem as quatro falhas, e vale escrevê-los mesmo que você use as views do Django, porque o que eles protegem é contra uma refatoração futura que "simplifique" o fluxo. Cada um passa pela sua view em vez de ir direto ao gerador. Um teste que só chama check_token() passa quer a sua view o chame ou não, então não pega essa refatoração.
from django.contrib.auth.tokens import default_token_generator
def test_a_spent_link_is_refused_on_replay(self):
token = default_token_generator.make_token(self.user)
first = self.client.post(self.confirm_url(token), {"password": "a new pass phrase"})
self.assertEqual(first.status_code, 200)
# O mesmo link, uma segunda vez, pela view e não pelo gerador.
second = self.client.post(self.confirm_url(token), {"password": "attacker's choice"})
self.assertEqual(second.status_code, 400)
def test_token_dies_if_the_password_changes_elsewhere(self):
token = default_token_generator.make_token(self.user)
self.user.set_password("changed it in settings instead")
self.user.save()
resp = self.client.post(self.confirm_url(token), {"password": "attacker's choice"})
self.assertEqual(resp.status_code, 400)
def test_token_expires(self):
token = default_token_generator.make_token(self.user)
later = datetime.now() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT + 60)
with mock.patch.object(default_token_generator, "_now", return_value=later):
resp = self.client.post(self.confirm_url(token), {"password": "attacker's choice"})
self.assertEqual(resp.status_code, 400)
def test_a_poisoned_host_cannot_change_the_link(self):
with self.settings(ALLOWED_HOSTS=["*"]): # a má configuração, de propósito
self.client.post(self.request_url, {"email": self.user.email},
HTTP_HOST="evil.test")
self.assertNotIn("evil.test", mail.outbox[-1].body)
def test_reset_request_does_not_reveal_membership(self):
known = self.client.post(self.request_url, {"email": self.user.email})
unknown = self.client.post(self.request_url, {"email": "nobody@example.test"})
self.assertEqual((known.status_code, known.content),
(unknown.status_code, unknown.content))
O jeito óbvio de escrever o primeiro teste está errado. Postar o link e depois perguntar check_token(self.user, token) falha contra uma view correta, porque self.user é uma cópia em memória desatualizada que ainda guarda o hash antigo. Acrescente refresh_from_db() e ele passa contra uma view que nunca chama check_token, porque qualquer escrita de senha invalida o token. Só reenviar o link pela view separa as duas.
Duas observações sobre os testes de expiração e de host, porque nenhum dos dois funciona do jeito mais óbvio. Para a expiração não há nada a envelhecer — o token não é uma linha — então você move o tempo, não o token; o PasswordResetTokenGenerator._now() existe como método exatamente por isso. E o override ALLOWED_HOSTS=["*"] não é uma conveniência para o teste passar. Sem ele, o Django rejeita o Host envenenado antes de a sua view rodar, e o teste passa por um motivo que nada tem a ver com o seu construtor de link. Sobrescrever remove o anteparo do framework para que o teste meça de fato o que diz medir — e ["*"] não é hipótese: foi o próprio padrão do Django 1.3.6.
Depois, a verificação que não precisa de framework de teste nenhum:
grep -rn "set_password" --include=*.py . | grep -v tests
Leia cada ocorrência e faça uma pergunta: como o chamador estabeleceu que esta requisição pode trocar esta senha? Para uma view autenticada de "trocar sua senha", a resposta é a sessão. Para qualquer coisa alcançada por um token, a resposta é melhor que seja check_token.
Fazendo a Varredura
Eu esperava que esta seção fosse curta. A diferença decisiva entre as duas views de confirmação do laboratório é uma chamada, check_token(), o tipo de coisa em que a análise estática deveria ser boa.
Nenhuma regra, em nenhuma camada, consegue diferenciá-las.
O Bandit não encontra nada em nenhum arquivo de view. Cinco achados, todos B105/B106 de senha embutida nos fixtures do laboratório. É o mesmo ponto cego do Post 13, chegando pelo outro lado: lá a chamada ausente era validate_password(), aqui é check_token(), e ausência não tem nó de AST de jeito nenhum. Um achado merece citação, porém, porque é o mais perto que qualquer ferramenta de prateleira chega:
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: '6f1c1e2a-9b7d-4a3f-8c21-0d5e7a9b4c33'
Location: labs/post_14_password_reset/seed.py:43:15
O Bandit sinalizou porque a variável se chama LEAKED_TOKEN: o B105 casa qualquer string atribuída a um nome que contenha token, secret ou pass. A heurística acerta a tese deste post, a de que um token de redefinição é uma credencial, mas só pelo nome. A propriedade a que ele objeta, um valor embutido em um fixture, é um artefato de laboratório e não o bug.
O Semgrep foi rodado em dois níveis. O primeiro são os pacotes curados (p/django, p/python, p/owasp-top-ten). Eles retornam dois achados, e são a mesma regra nas duas views:
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_14_password_reset/
# Ran 156 rules on 17 files: 2 findings.
A regra é use-none-for-password-default, e ela aponta a linha new_password = request.POST.get("password", ""), que existe igual em views_vulnerable.py:88 e em views_secure.py:85. A preocupação dela é que o padrão "" chegue ao set_password() e grave uma senha vazia. Aqui é um falso positivo: a linha seguinte, if not new_password: return 400, recusa a senha vazia, e a regra não olha essa linha. E, mesmo que o achado fosse verdadeiro, ele não diria nada sobre o token ter sido verificado.
O segundo nível são os conjuntos completos do registry (r/python.django e r/python), que incluem regras que os pacotes curados deixam de fora, muitas delas da subcategoria audit, mais ruidosa. Foi esse nível que encontrou o bug nos Posts 8 e 13. Aqui ele roda 372 regras e retorna 17 achados. Sete caem em arquivos de apoio (o seed, os testes e a view do cofre). Os outros dez caem nas duas views, e cada regra aparece nas duas na mesma quantidade:
| Regra | O que ela aponta | views_vulnerable.py |
views_secure.py |
|---|---|---|---|
no-csrf-exempt |
uso de @csrf_exempt |
L43, L76 | L47, L77 |
unvalidated-password |
set_password() sem validate_password() |
L92 | L89 |
use-none-for-password-default |
padrão "" para a senha |
L88 | L85 |
direct-use-of-httpresponse |
dado enviado direto em um HttpResponse, sem template |
L98 | L94 |
Nenhuma dessas regras é sobre o token. Todas apontam algo que as duas views têm em comum, então o resultado é idêntico dos dois lados, e nada nele indica que uma das views deixa de chamar check_token().
Para conferir se isso era um traço desta classe de bug, e não só dos meus dois arquivos, rodei as mesmas cinco configurações contra o arquivo de teste da regra customizada que aparece mais abaixo. Esse arquivo tem seis funções que chamam set_password(): três views de redefinição quebradas, uma delas o ponto cego que a regra aceita, e três corretas, entre elas uma view comum de "trocar a própria senha", em que o usuário já está logado e não há token nenhum. Só uma regra disparou, a unvalidated-password, e disparou nas seis. Seis funções, seis achados, nenhuma distinção entre as quebradas e as corretas.
Isso não quer dizer que a regra esteja errada. A unvalidated-password cuida de outra coisa: da força da senha (CWE-521). Ela dispara em todo set_password() que não seja precedido de um validate_password(), e nenhuma das seis funções chama validate_password(), então, pelos critérios dela, as seis marcações estão certas. O que falta é uma regra que pergunte se o token de redefinição foi verificado, e nenhuma regra do registry faz essa pergunta. Respondê-la em qualquer código exigiria seguir o token por funções auxiliares e métodos de classe até a chamada que o verifica, o que se chama análise interprocedural, e a versão comunitária do Semgrep não faz isso. Dentro de uma única função, porém, uma regra simples, que só olha a forma do código, já basta.
Esse é o desfecho "não consegue distinguir" da série: o Bandit não vê o bug, e o Semgrep sinaliza o código vulnerável e o seguro da mesma forma. É o caso que justifica escrever uma regra própria. A rules/password_reset.yaml procura uma função que receba um parâmetro com "token" no nome, chame set_password() e não chame check_token() em nenhum ponto do corpo:
semgrep scan --config rules/password_reset.yaml \
labs/post_14_password_reset/views_vulnerable.py \
labs/post_14_password_reset/views_secure.py
# ❯❯❱ rules.thiagoteixeira.django.security.password-reset.reset-token-never-verified
# labs/post_14_password_reset/views_vulnerable.py
# Ran 1 rule on 2 files: 1 finding.
Ela dispara na view vulnerável e fica calada na segura. Exigir o parâmetro com "token" no nome é o que a mantém longe das views legítimas de trocar a própria senha, que chamam set_password() sem token nenhum e que a unvalidated-password sinaliza junto com todo o resto. Por ser uma regra que só olha a forma do código, ela tem limites conhecidos. Basta qualquer check_token() no corpo para silenciá-la, mesmo que o resultado seja ignorado ou que a verificação seja feita no usuário errado. Ela não pega um token cujo parâmetro tenha outro nome, nem um token lido do corpo do POST. E dispara por engano em uma view baseada em classe que verifica o token no dispatch() e troca a senha no post().
Um detalhe da regra faz diferença. Para reconhecer o check_token(), ela usa o operador de expressão profunda do Semgrep, <... $GEN.check_token(...) ...>, que encontra a chamada em qualquer ponto de uma instrução, inclusive dentro de uma condição como if not default_token_generator.check_token(user, token):. Sem ele, o padrão só reconheceria a chamada sozinha na linha ou atribuída a uma variável, e a regra sinalizaria justamente a view segura, escrita do jeito mais comum.
A redefinição de senha merece uma auditoria própria, separada do resto do seu código de autenticação, porque é uma segunda entrada para todas as contas, construída à parte do login. O hábito a levar adiante é uma única pergunta, feita a todo caminho de código que alcança set_password(): o que provou que esta requisição podia fazer isso? Se a resposta for uma linha em uma tabela, procure a verificação que recusa um token vencido — e, se não a encontrar, você achou a vulnerabilidade deste post no seu próprio código. O próximo da série é autenticação multifator, que é o controle que continuava de pé no comunicado do GitLab depois que a senha já tinha sido trocada.
Leitura Complementar
- django-security-lab —
labs/post_14_password_reset/— o laboratório executável: um fluxo de redefinição feito à mão, o do Django, um token vazado de 400 dias e a saída capturada dos scanners emscans/ - django-security-lab —
rules/password_reset.yaml— a regra customizada do Semgrep e seu fixture de teste - Django Docs — Authentication views (
PasswordResetView,PasswordResetConfirmView) - Django Docs — Security: Host header validation
- OWASP A07:2025 — Authentication Failures
- OWASP — Forgot Password Cheat Sheet
- PortSwigger Web Security Academy — Exploiting HTTP Host header vulnerabilities
- MITRE ATT&CK — T1098: Account Manipulation
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Capítulo 10: Authentication
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress) — Capítulo 7: Authentication and Authorization