Senhas Fracas e Validadores: Por Que os Quatro Padrões do Django Aceitam uma Senha Vista 295.389 Vezes em Vazamentos

Senhas Fracas e Validadores: Por Que os Quatro Padrões do Django Aceitam uma Senha Vista 295.389 Vezes em Vazamentos

Django Security Series — Post 13 | Série III: Autenticação e Sessão
OWASP A07:2025 — Authentication Failures | Tempo de leitura: ~29 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: três views de cadastro e uma vítima cuja senha todos os validadores padrão aprovaram. Uma das views faz o hash da senha sem nunca validá-la, então a conta existe com Password123! — e o atacante precisa de um palpite, não de uma wordlist, para ler uma flag que só ela deveria ver. As outras duas validam, uma inline e outra em um Form, e recusam essa senha de imediato. Reproduza tudo com curl, depois rode o Bandit e o Semgrep sobre os mesmos arquivos e veja qual deles percebe — e qual erra o alvo.

Escrevi sete validadores de senha personalizados para o Petição Brasil, empilhados sobre os quatro embutidos do Django. Quatro dos meus sete são regras de classe de caractere: a senha precisa conter uma letra maiúscula, uma minúscula, um dígito e um caractere especial. Eu estava satisfeito com aquela pilha. Parecia diligência — onze validadores entre o usuário e uma senha ruim, cada um uma classe pequena e testável, com sua própria mensagem de erro em dois idiomas.

Então li a versão atual da norma que todo mundo nesta área cita e quase ninguém lê até o fim. A NIST Special Publication 800-63B-4, publicada em 31 de julho de 2025, diz o seguinte, na voz normativa que o documento reserva para requisitos rígidos:

Verifiers and CSPs SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types) for passwords.

Não "should not". Shall not. Num documento normativo do NIST a diferença entre os dois não é de estilo: um recomenda, o outro proíbe. A revisão 3 dizia "should not" desde 2017; a revisão 4 endureceu a frase. Quatro dos meus sete validadores personalizados são exatamente o que ela proíbe. Não os escrevi porque tivesse qualquer evidência de que funcionavam. Escrevi porque é essa a cara de uma política de senhas, e porque ver um formulário rejeitar uma senha fraca parecia estar fazendo alguma coisa.

Deixe-me ser preciso sobre qual metade daquilo é de fato o bug, no entanto. O Petição Brasil chama validate_password() antes de salvar uma nova senha, no clean_password1() do formulário de redefinição, com o objeto de usuário passado — então o fluxo está ligado corretamente, e o modo de falha ao qual este post dedica a maior parte do seu tempo é um que evitei. O formato da política é a parte que errei. Portanto há dois defeitos diferentes aqui, e este post precisa cobrir os dois: o desenvolvedor que nunca chama a API de validação, e o desenvolvedor que a chama religiosamente contra um conjunto de regras que mede a coisa errada.

E há a terceira coisa, que é o que de fato mudou minha opinião. Os quatro validadores padrão do Django — o bloco que o startproject escreve no seu arquivo de settings, o bloco que você quase certamente nunca editou — aceitam a senha Password123!. Essa string já apareceu 295.389 vezes nos corpora de vazamento por trás do Have I Been Pwned. Não li esse número em um artigo; consultei a API para obtê-lo enquanto escrevia este parágrafo, e você pode reproduzir a consulta em quatro linhas de Python antes do fim deste post.


O Ataque: O Que É e Como Funciona

Não há payload aqui, e é por isso que este post se encaixa de um jeito um pouco estranho ao lado do resto da série. Todos os outros tiveram uma string no centro — uma aspa injetada, uma expressão de template, uma sequência de travessia, um id de sessão fixado. Senhas fracas não têm esse artefato. O atacante envia uma requisição de login comum, carregando uma senha comum. A vulnerabilidade não está na requisição de forma alguma. Está no fato de a senha ser adivinhável, e ela era adivinhável porque a sua aplicação concordou em armazená-la.

O que torna uma senha adivinhável não é seu comprimento nem seu conjunto de caracteres em abstrato. É sua posição em uma lista ordenada. Atacantes não enumeram espaços de chaves; enumeram corpora, ordenados por frequência observada, porque a escolha humana de senhas é absurdamente concentrada. A lista canônica é a rockyou.txt: 14.344.392 senhas únicas extraídas de um único vazamento de 2009. Ela ainda vem por padrão no Kali Linux, e dezesseis anos depois continua sendo o primeiro arquivo que a maioria dos testadores busca. Em cima da lista ficam as regras de mangling: transformações mecânicas aplicadas a cada candidata — colocar a primeira letra em maiúscula, acrescentar um dígito, trocar a por @, acrescentar o ano corrente. Um conjunto de regras de cracking com algumas dezenas de transformações multiplica uma wordlist de catorze milhões de entradas em um espaço de candidatas na casa das centenas de milhões, e hardware moderno percorre isso em minutos.

É aqui que as regras de composição fazem seu estrago, e o mecanismo corre ao contrário do que você esperaria. Uma regra que diz "sua senha precisa conter uma maiúscula, um dígito e um símbolo" não empurra os usuários para o meio do espaço de chaves. Empurra-os para um canto previsível dele, porque humanos satisfazem esse tipo de regra de um número pequeno de formas estereotipadas: a maiúscula vai na primeira letra, os dígitos vão no fim, o símbolo vem depois dos dígitos, e a palavra-base é uma palavra de dicionário. Password123! não é um ponto aleatório em um alfabeto de 95 caracteres elevado à décima segunda potência. É password com três transformações aplicadas, todas elas presentes por padrão em qualquer conjunto de regras de cracking. A regra de composição não acrescentou entropia. Ela estreitou a busca.

Uma senha fraca acaba sendo explorada de uma destas três maneiras, e cada uma corresponde a um post diferente da série. Adivinhação online repete candidatas contra o seu formulário de login ao vivo, que é o que rate limiting e bloqueio de conta (Post 11) existem para retardar. Cracking offline acontece depois que um banco de dados é roubado, contra os hashes armazenados, na velocidade que o seu algoritmo de hash e as GPUs do atacante permitirem — o armazenamento de senhas ganha um post próprio mais adiante na série. Credential stuffing pula a adivinhação por completo e repete um par usuário/senha colhido do vazamento de outra pessoa, que é onde senhas reutilizadas mordem e onde a autenticação multifator vira a resposta. As três defesas são reais, e as três só entram em ação depois que a senha fraca já existe. Elas tornam uma senha fraca mais cara de explorar.

O validador de senha é o único controle em toda essa cadeia que age antes disso tudo, no momento da escolha, do lado da oferta. Ele não torna uma senha fraca mais difícil de explorar; impede que a senha fraca exista no seu banco de dados. Errar nisso também é completamente silencioso. Nada falha, nada é registrado, nenhum alerta dispara. Um usuário escolhe Password123!, seus validadores deixam passar, e a conta fica no seu admin com exatamente a mesma aparência de todas as outras até o dia em que alguém roda uma lista contra ela. O defeito é entregue como dado, não como código. Você nunca vai encontrá-lo lendo suas views.


Incidentes Reais

RockYou — 32,6 milhões de e-mails e senhas em texto puro (dezembro de 2009); acordo com a FTC anunciado em 27 de março de 2012

A RockYou fazia widgets e jogos sociais para Facebook e MySpace. Em dezembro de 2009 um atacante usando o codinome "igigi" extraiu a tabela inteira de usuários por meio de uma falha de SQL injection — a vulnerabilidade do Post 1 — e descobriu que todos os 32,6 milhões de e-mails e senhas estavam armazenados em texto puro. Sem hash, sem salt, sem criptografia de qualquer tipo. As credenciais não foram apenas expostas; foram expostas de forma legível, que é a diferença entre um vazamento que custa ao atacante meses de GPU e um vazamento que lhe custa um download.

Escolhi este incidente em vez de um maior ou mais recente porque a RockYou é o único vazamento desta série que não apenas expôs dados — ele construiu uma arma que continua em uso diário. As senhas despejadas, deduplicadas, viraram a rockyou.txt. Todo ataque de adivinhação de senha descrito na seção anterior, aqui e no Post 11, é movido por uma lista do que 32 milhões de pessoas reais de fato escolheram em 2009, e ainda funciona, porque a distribuição da escolha humana de senhas mal se moveu. 123456 era a senha mais comum daquele despejo. É a linha um da lista de 19.640 entradas que vem dentro do Django hoje. A blocklist que o seu framework lhe entrega é feita deste vazamento.

O MITRE ATT&CK mapeia a atividade subsequente como T1110 — Brute Force. O Post 11 cobriu suas subtécnicas de stuffing e spraying; este post vive um nível abaixo na mesma família, em T1110.001 — Password Guessing (candidatas disparadas contra um endpoint de autenticação ao vivo) e T1110.002 — Password Cracking, a variante offline que a RockYou tornou trivial ao pular o hashing por completo. A distinção importa operacionalmente: adivinhação é visível nos seus logs e respondível com throttling; cracking acontece no hardware do atacante depois do fato e só é respondível pelo que você decidiu sobre hashing e força de senha antes do roubo.

O desfecho jurídico é a parte à qual eu sempre volto, porque não é a parte que a maioria dos textos cita. A FTC não acusou a RockYou principalmente pelos 32 milhões de adultos. Acusou a empresa sob o Children's Online Privacy Protection Act, porque por cerca de dois anos a RockYou havia aceitado conscientemente cadastros de crianças menores de 13 anos — aproximadamente 179 mil delas — coletando e-mails, senhas, ano de nascimento, sexo, CEP e país sem consentimento parental verificável. O acordo de 27 de março de 2012 exigiu uma multa civil de US$ 250 mil nas acusações de COPPA, um programa abrangente de segurança de dados e auditorias independentes de segurança por terceiros a cada dois anos por vinte anos. No lado da segurança, a conclusão da Comissão foi que a RockYou falhara em manter "procedimentos razoáveis, como criptografia, para proteger a confidencialidade, a segurança e a integridade de informações pessoais coletadas de crianças" — enquanto, na caracterização da FTC, promovia seus recursos de segurança aos usuários.

Leia isso do Brasil e o mapeamento é quase um para um. O Art. 46 da LGPD exige que agentes de tratamento adotem "medidas de segurança, técnicas e administrativas aptas a proteger os dados pessoais". "Aptas" é um padrão de proporcionalidade, não um checklist, e é avaliado contra o que estava razoavelmente disponível à época. Um bloco AUTH_PASSWORD_VALIDATORS de quatro linhas que o seu framework gera para você é mais ou menos o máximo de disponibilidade que uma medida de segurança pode ter; um algoritmo de hash que você obtém sem fazer nada também. E o Art. 14 da LGPD faz com dados de crianças o que a COPPA fez: o tratamento de dados pessoais de crianças deve ser realizado em seu melhor interesse e, pelo §1º, exige "consentimento específico e em destaque dado por pelo menos um dos pais ou pelo responsável legal". A lição da RockYou não é "criptografe suas senhas". É que a categoria de titular de dados eleva silenciosamente o padrão que a sua engenharia precisa atingir, e que um regulador vai ler o seu material de marketing de volta para você como prova do que você prometeu. Se o seu formulário de cadastro é alcançável por alguém de catorze anos — e se ele está na internet pública, é — então a senha fraca que você aceitou está atrelada a um titular que a lei protege mais estritamente do que aqueles para quem você projetou.

Fontes: FTC — Charges That Security Flaws in RockYou Game Site Exposed 32 Million Email Addresses and Passwords (2012) · TechCrunch — RockYou Hack: From Bad To Worse (2009)


Proteções Padrão do Django

Esta é uma linha do próprio código-fonte do Django, e ela reenquadrou o tema inteiro para mim:

# django/conf/global_settings.py
AUTH_PASSWORD_VALIDATORS = []

O padrão real do Django é nenhuma validação de senha. Os quatro validadores em que você está pensando não vivem nos defaults do framework; vivem no template do startproject, que os escreve no seu settings.py no momento em que você cria o projeto. A distinção importa. O Django não está mantendo essa proteção para você. Ele a entregou uma vez, como texto, em um arquivo que é seu e que você edita. Apague o bloco, ou comece de um módulo de settings que não foi gerado pelo startproject (um cookiecutter, o boilerplate de um colega, uma configuração doze-fatores escrita à mão), e toda senha no seu site é aceita. Não há aviso. O manage.py check --deploy não menciona isso.

Supondo que o bloco esteja lá, eis o que os quatro validadores de fato fazem — lido de django/contrib/auth/password_validation.py no Django 5.2.17, não da documentação.

UserAttributeSimilarityValidator compara a senha em minúsculas com o username, first_name, last_name e email do usuário, e com cada parte delimitada por \W+ desses valores, usando difflib.SequenceMatcher.quick_ratio() com um limiar max_similarity de 0.7. Quebrar em caracteres não-palavra é o motivo de um endereço de e-mail proteger thiago e peticaobrasil independentemente, não apenas o endereço inteiro. Ele também abre com um curto-circuito muito fácil de ler por cima:

def validate(self, password, user=None):
    if not user:
        return

Sem usuário, sem verificação. Toda regra de similaridade neste validador desaparece silenciosamente quando ele é chamado sem um objeto de usuário — e a assinatura de validate_password(password, user=None) faz da omissão o caminho de menor resistência.

MinimumLengthValidator tem min_length=8 por padrão. O template do startproject não sobrescreve isso. Contra a NIST SP 800-63B-4, que exige um mínimo de 15 caracteres para uma senha usada como fator único (e permite 8 apenas quando a senha é um dos fatores de um esquema multifator), o piso de fábrica do Django é hoje cerca de metade do padrão atual para o modo como a maioria dos sites de fato autentica.

CommonPasswordValidator carrega uma lista comprimida que vem dentro do pacote e rejeita a senha se password.lower().strip() aparecer nela. A docstring diz que a lista contém 20.000 entradas. O arquivo que vem com a 5.2.17 conta 19.640, todas únicas, ordenadas por frequência com 123456 em primeiro. Contei duas vezes, supondo que tivesse errado na primeira. Qualquer um dos dois números é simultaneamente adequado e pequeno demais. É uma lista bem escolhida das senhas mais comuns já registradas, e tem cerca de 0,14 % do tamanho da rockyou.txt sozinha — antes de contar os quinze anos de corpora de vazamento desde então.

NumericPasswordValidator é uma linha: if password.isdigit(). Existe porque senhas de data de nascimento e número de telefone são comuns o bastante para merecerem a própria regra.

Então, o que essa pilha aceita? Rodei os quatro contra um conjunto de candidatas, com um objeto de usuário preenchido, no Django 5.2.17:

Senha Resultado
123456 rejeitada — curta demais, comum demais, inteiramente numérica
password rejeitada — comum demais
password123 rejeitada — comum demais
abcdefgh rejeitada — comum demais
qwerty123 rejeitada — comum demais
P@ssw0rd rejeitada — comum demais
thiago2026 rejeitada — parecida demais com o nome de usuário
peticaobrasil rejeitada — parecida demais com o e-mail
Password123! aceita
Summer2026! aceita
Tr0ub4dor&3 aceita
correct horse battery staple aceita

As quatro de baixo passam todas. Agora as mesmas quatro contra o corpus de vazamentos do Have I Been Pwned, que conta quantas vezes cada senha apareceu nos vazamentos que o HIBP ingeriu:

Senha Vezes vista em vazamentos
Password123! 295.389
Tr0ub4dor&3 3.196
correct horse battery staple 391
Summer2026! 45

Esse par de tabelas é o argumento inteiro deste post. Os padrões do Django rejeitam P@ssw0rd e aceitam Password123!, e a segunda delas é duas ordens de grandeza mais comum em vazamentos reais do que Tr0ub4dor&3. Essa vem da xkcd 936, "Password Strength" — a tirinha de humor sobre tecnologia que toda discussão sobre senhas acaba citando —, onde ela é o exemplo ruim: exatamente o formato que uma regra de composição produz quando obriga alguém a acrescentar uma maiúscula, um dígito e um símbolo a uma palavra de dicionário. A linha do correct horse battery staple é a de que mais gosto: a passphrase que uma geração de engenheiros aprendeu como o exemplo de uma senha boa já foi vazada 391 vezes, porque foi publicada como exemplo e as pessoas a usaram como uma. Cada um desses números é reproduzível; o código que os produz está na seção de testes abaixo.

Uma última peça do quadro padrão: onde os validadores rodam automaticamente, e onde não rodam. No Django 5.2 a chamada de validação vive em SetPasswordMixin.validate_password_for_user(), que faz exatamente o que você escreveria à mão —

# django/contrib/auth/forms.py
password = self.cleaned_data.get(password_field_name)
if password:
    try:
        password_validation.validate_password(password, user)
    except ValidationError as error:
        self.add_error(password_field_name, error)

— e esse mixin é usado por BaseUserCreationForm (via _post_clean), UserCreationForm, AdminUserCreationForm, SetPasswordForm, PasswordChangeForm e AdminPasswordChangeForm. Se seus fluxos de cadastro, troca e redefinição de senha passam por esses formulários, você está validado e não precisou fazer nada.

Todo o resto não é validado:

  • user.set_password(pw) — o corpo é self.password = make_password(raw_password). Sem validação, por design.
  • User.objects.create_user(...) — seu _create_user_object() chama make_password(password) diretamente. Sem validação.
  • Qualquer serializer do DRF, Form personalizado, mutation GraphQL, management command ou action do admin que defina uma senha por conta própria.
  • createsuperuser --noinput. O comando chama validate_password() — mas apenas dentro do seu ramo if options["interactive"]:. O caminho não interativo lê DJANGO_SUPERUSER_PASSWORD do ambiente e escreve direto. E mesmo interativamente, uma validação que falha imprime os erros e então pergunta Bypass password validation and create user anyway? [y/N]:. A conta com mais privilégio no seu sistema é aquela a que o Django está mais disposto a dar uma senha ruim, porque presume que há um operador do outro lado.

Padrão Vulnerável: O Que NÃO Fazer

Padrão 1 — Um caminho de cadastro que define a senha diretamente

Este é o caso comum, e quase nunca parece errado, porque o desenvolvedor que o escreveu estava pensando no modelo de usuário, não na senha. Todo fluxo de onboarding personalizado, aceite de convite, cadastro por API e action de admin do tipo "criar um usuário para este cliente" acaba aqui.

# PERIGO: nada nesta função consulta AUTH_PASSWORD_VALIDATORS.
def register(request):
    username = request.POST["username"]
    password = request.POST["password"]

    user = User(username=username, email=request.POST.get("email", ""))
    user.set_password(password)   # faz o hash. Não valida.
    user.save()

    return redirect("login")

set_password() faz o hash corretamente. Usa o hasher configurado, aplica salt, faz tudo o que o armazenamento correto de senhas exige dele — e é justamente essa competência que faz a linha parecer terminada. Validação é um subsistema separado, com um ponto de entrada separado, e nada na camada de modelo o alcança. User.objects.create_user(username=..., password=...) tem a lacuna idêntica pela razão idêntica. Um usuário que se cadastre por esta view pode escolher 1.

Padrão 2 — O bloco de settings, apagado ou diluído

# PERIGO: esta é uma configuração real que já vi mais de uma vez.
AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validators.MinimumLengthValidator",
        "OPTIONS": {"min_length": 6},
    },
]

Duas falhas em cinco linhas. A óbvia é que três validadores foram removidos e o piso de comprimento baixado para 6 — o que aceita 123456, a senha mais comum já registrada, em um site que acredita ter uma política de senhas. A mais sutil é o caminho de importação: password_validators em vez de password_validation. O Django levanta ImproperlyConfigured para isso — mas get_default_password_validators() é decorado com @functools.cache e só é chamado preguiçosamente, na primeira senha validada. Então a má configuração não quebra o boot, não quebra o manage.py check e não quebra sua suíte de testes, a menos que algum teste de fato valide uma senha. Ela quebra em produção, no seu formulário de cadastro, na primeira vez que um usuário real se inscreve.

Ninguém apaga aquele bloco maliciosamente, o que é justamente o problema. Ele some quando alguém está consolidando settings, ou por um módulo de settings construído do zero para um deploy doze-fatores, ou por um desenvolvedor que esbarrou no UserAttributeSimilarityValidator enquanto escrevia uma fixture de teste, achou irritante, comentou e seguiu em frente. Não há sinal depois disso. O site continua funcionando; ele simplesmente para de dizer não.

Padrão 3 — Chamar o validador, mas sem o usuário

Este é o padrão que mais me incomoda, porque é escrito por um desenvolvedor que sabia da API de validação e a buscou deliberadamente.

# PERIGO: a chamada é real. Um dos quatro validadores fica silenciosamente inerte.
from django.contrib.auth.password_validation import validate_password

def set_new_password(request):
    password = request.POST["password"]
    validate_password(password)          # <-- sem o argumento user
    request.user.set_password(password)
    request.user.save()

validate_password(password) roda MinimumLengthValidator, CommonPasswordValidator e NumericPasswordValidator normalmente. O UserAttributeSimilarityValidator bate no if not user: return e não faz absolutamente nada. Um usuário chamado thiago com o e-mail thiago@peticaobrasil.com.br pode definir sua senha como thiago@peticaobrasil.com.br, e este código vai aceitar, tendo chamado a API de validação, não tendo levantado erro algum, e tendo produzido um diff que parece uma melhoria de segurança na revisão.

Há uma variante pior e igualmente comum: chamar validate_password() depois de set_password() e save(), de modo que a exceção — quando dispara — é levantada depois de a senha fraca já estar gravada. E uma terceira: capturar ValidationError e registrá-la em log em vez de relançá-la, o que transforma o validador em uma forma muito cara de gerar linhas de log.


Implementação Segura: O Jeito Django

Regra 1 — Chame validate_password(password, user) em todo caminho que define uma senha

A correção do Padrão 1 é uma linha, no lugar certo, com os dois argumentos:

from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError

def register(request):
    username = request.POST["username"]
    password = request.POST["password"]

    user = User(username=username, email=request.POST.get("email", ""))

    try:
        # Passe o usuário não salvo: o UserAttributeSimilarityValidator precisa do
        # username e do e-mail para comparar, e vira no-op silencioso sem eles.
        validate_password(password, user)
    except ValidationError as exc:
        return render(request, "register.html", {"errors": exc.messages}, status=400)

    user.set_password(password)
    user.save()
    return redirect("login")

Três detalhes ali carregam o peso. A validação acontece antes do set_password(), então uma senha rejeitada nunca chega ao hasher nem ao banco. O objeto de usuário é passado, mesmo sem ter sido salvo — o validador só lê atributos, então uma instância não salva funciona e é exatamente o que o createsuperuser constrói para o mesmo fim. E o ValidationError é exibido ao usuário, com exc.messages entregando as mesmas strings traduzidas que os formulários embutidos mostram.

A resposta melhor, onde couber, é não escrever isso: herde de BaseUserCreationForm ou SetPasswordForm e deixe o mixin fazer o trabalho. Escrever à mão é para os caminhos que um formulário não cobre — serializers do DRF, fluxos de convite, management commands — e neles a regra é a mesma: uma chamada, antes da escrita, com o usuário.

Há um terceiro parâmetro que vale conhecer, porque AUTH_PASSWORD_VALIDATORS é um setting global e às vezes você quer uma política que não seja. validate_password(password, user, password_validators=[...]) recebe uma lista explícita e ignora o setting por completo, e get_password_validators() constrói essa lista a partir dos mesmos dicionários {"NAME": ..., "OPTIONS": ...} que você colocaria em settings. Recorra a isso quando um fluxo genuinamente precisar de regras diferentes — um console restrito a staff com um piso mais alto, digamos, ou uma biblioteca que entrega uma política sem impô-la ao projeto que a instala. O laboratório companheiro usa isso pela segunda razão: seu piso de 15 caracteres teria, de outro modo, valido para todos os outros laboratórios do repositório, vários dos quais usam senhas fracas deliberadamente.

Regra 2 — Defina o piso de comprimento que a norma de fato exige agora

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
        # NIST SP 800-63B-4 §3.1.1.2 exige ("SHALL") no mínimo 15 caracteres para
        # uma senha usada como fator único. 8 só é permitido atrás de MFA.
        "OPTIONS": {"min_length": 15},
    },
    # ...
]

Quinze soa hostil até você notar o que isso faz com o formato do que os usuários escolhem: aos quinze caracteres, palavras de dicionário modificadas deixam de ser o caminho de menor resistência e passphrases passam a ser. É esse o mecanismo inteiro. Você não está pedindo a ninguém que memorize mais entropia por caractere; está tornando indisponíveis os formatos curtos e previsíveis.

Não coloque um teto. A NIST pede que verificadores permitam ao menos 64 caracteres, e cumprir isso não custa nada no Django: set_password() faz o hash antes de armazenar, então o max_length=128 da coluna password guarda um hash de largura fixa independentemente do tamanho da entrada. Uma ressalva que vale conhecer antes de chegar perto de hashers — se você trocar para o BCryptPasswordHasher, o bcrypt trunca a senha em 72 bytes, então uma passphrase de 100 caracteres silenciosamente vira só 72 bytes de segredo. O Django traz o BCryptSHA256PasswordHasher especificamente para pré-hashear e contornar isso, e é ele que você quer. O PBKDF2PasswordHasher padrão não tem esse limite.

Regra 3 — Apague as regras de composição

Esta é a que mais resisti, então prefiro fazer o argumento a apenas apontar para a norma.

Regras de composição são uma medida indireta. Elas perguntam "esta senha contém os ingredientes de uma senha forte?" quando a pergunta que importa é "quão fundo em uma lista ordenada de candidatas esta senha está?". Essas duas perguntas concordam em strings aleatórias e discordam em tudo o que um humano digita, porque humanos satisfazem exigências de composição por meio de um conjunto pequeno e bem conhecido de transformações que os conjuntos de regras de cracking codificam por padrão. Impor a regra diz ao atacante quais transformações aplicar. É uma dica, entregue como requisito.

A versão empírica desse argumento são as duas tabelas acima. Password123! satisfaz toda regra de composição que alguém já escreveu, e já foi vista 295.389 vezes. correct horse battery staple não satisfaz nenhuma e foi vista 391 vezes — e mesmo isso só porque foi publicada como exemplo. Arranje esses dois fatos como quiser; as classes de caractere não são o sinal.

Então remova-as e coloque o esforço em comprimento (Regra 2) e em uma blocklist de verdade (Regra 4). Duas ressalvas. Primeira: se você opera sob um regime de conformidade que exige regras de composição — algumas regulações setoriais e muitos questionários corporativos de segurança ainda exigem —, mantenha-as, documente que está atendendo a um requisito externo e não a um de segurança, e garanta que o piso de comprimento e a blocklist estejam fazendo o trabalho de verdade. Segunda: não confunda isso com uma licença para baixar a régua. A NIST remove regras de composição porque eleva o mínimo de comprimento e torna obrigatória a checagem contra vazamentos. Abandonar uma sem adotar as outras não é modernização, é desregulamentação.

Para o Petição Brasil a conclusão é menos arrumada que isso. Quatro dos meus sete validadores personalizados são exatamente o que a norma proíbe, então esses têm de sair. O resto da lista — de 8 para 15 caracteres, uma blocklist de verdade — eu não fiz, e o motivo é alcance do dano, não fila de tarefas: uma senha do Petição Brasil permite criar uma petição, não assiná-la. A assinatura passa pela PKI do gov.br, e é o certificado que a torna vinculante, não a sessão que chegou ao formulário. Então aceitei o risco pelo impacto — o que é uma decisão defensável, e ainda assim uma frase desconfortável de escrever no fim de um post que argumenta que esse defeito é entregue como dado que ninguém nunca olha.

Regra 4 — Verifique a senha contra uma lista de vazamentos de verdade

A lista que vem no Django tem 19.640 entradas. O corpus de vazamentos por trás do Have I Been Pwned é um universo de escala diferente, e é consultável de graça, sem chave de API, sem nunca transmitir a senha — via k-anonimato. Você faz o SHA-1 da senha, envia apenas os cinco primeiros caracteres hex do hash, e recebe de volta todos os sufixos daquele balde com sua contagem de vazamentos; a comparação final você faz localmente. O servidor aprende em qual de aproximadamente um milhão de baldes sua senha caiu, e nada mais.

# labs/post_13_weak_passwords/validators.py
import hashlib
import urllib.error
import urllib.request

from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

RANGE_URL = "https://api.pwnedpasswords.com/range/{prefix}"


class PwnedPasswordValidator:
    """Rejeita senhas que aparecem no corpus de vazamentos do Have I Been Pwned.

    Usa a range API de k-anonimato: apenas os 5 primeiros caracteres do hash
    SHA-1 deixam este processo. A senha em si nunca é transmitida.
    """

    def __init__(self, threshold=1, timeout=2.0, fail_open=True):
        self.threshold = threshold
        self.timeout = timeout
        self.fail_open = fail_open

    def _fetch(self, prefix):
        """Retorna o corpo bruto sufixo:contagem da API para um balde de 5 caracteres."""
        request = urllib.request.Request(
            RANGE_URL.format(prefix=prefix),
            # Preenche a resposta com sufixos aleatórios para que um observador não
            # possa inferir o tamanho do balde — e portanto estreitar a senha — a
            # partir do comprimento da resposta criptografada.
            headers={"Add-Padding": "true"},
        )
        with urllib.request.urlopen(request, timeout=self.timeout) as response:
            return response.read().decode("utf-8")

    def validate(self, password, user=None):
        # SHA-1 não é uma escolha aqui — é o formato de fio que a range API define,
        # e é uma chave de busca em um corpus público, não uma proteção.
        # usedforsecurity=False diz isso, mantém a chamada funcionando em builds
        # FIPS, e é o que impede o B324 do Bandit de disparar nesta linha.
        digest = hashlib.sha1(
            password.encode("utf-8"), usedforsecurity=False
        ).hexdigest().upper()
        prefix, suffix = digest[:5], digest[5:]

        try:
            body = self._fetch(prefix)
        except (urllib.error.URLError, OSError):
            if self.fail_open:
                return          # disponibilidade acima da política — veja a nota abaixo
            raise ValidationError(
                _("Could not verify this password against the breach database. "
                  "Please try again."),
                code="pwned_check_unavailable",
            )

        for line in body.splitlines():
            candidate_suffix, _sep, count = line.partition(":")
            if candidate_suffix == suffix and int(count or 0) >= self.threshold:
                raise ValidationError(
                    _("This password has appeared in a known data breach and "
                      "cannot be used."),
                    code="password_pwned",
                )

    def get_help_text(self):
        return _("Your password can't be one that has appeared in a data breach.")

É só biblioteca padrão — sem httpx, sem requests — então cai em qualquer
projeto Django do jeito que está. Há quatro decisões ali que quero defender.

Add-Padding: true não é cosmético. Sem ele, o balde 49EFE retorna 1.950 sufixos em 76.614 bytes, e a resposta é cacheável (Cache-Control: public, max-age=2678400). Com padding ele retorna 2.112 linhas e Cache-Control: no-store. O tamanho da resposta é um canal lateral — um observador que vê uma resposta criptografada de comprimento conhecido consegue estreitar qual balde você pediu — e o padding fecha isso, ao custo de abrir mão do cache de CDN.

fail_open=True é um padrão deliberado e discutível. Se o HIBP estiver inalcançável, este validador deixa a senha passar em vez de travar todo cadastro e toda redefinição de senha do seu site atrás do uptime de um terceiro. Para a maioria das aplicações de consumo essa é a troca certa, e para um banco é a errada. Fiquei indo e voltando nisso por mais tempo do que esperava. Faça a escolha deliberadamente e escreva para que lado você foi; um validador que falha aberto porque ninguém pensou na rede é um artefato muito diferente de um que falha aberto de propósito.

threshold=1 rejeita qualquer coisa vista nem que seja uma vez. Você pode elevá-lo — threshold=10 rejeita apenas senhas com prevalência real — se o ajuste estrito recusar escolhas legítimas demais. Note o que o ajuste estrito faz com correct horse battery staple: 391 ocorrências, rejeitada. Esse é o comportamento correto, e ainda assim vai gerar um chamado no suporte.

Nunca envie o hash inteiro. Toda a propriedade de segurança deste desenho é o prefixo de cinco caracteres. Uma implementação "mais simples" que envia o SHA-1 completo para um serviço de consulta não é uma checagem de vazamento; é um endpoint de divulgação de senha com um nome simpático.

Combine isso com uma lista local ampliada para a metade offline. O CommonPasswordValidator aceita um password_list_path, então você pode apontá-lo para o seu próprio arquivo — um corpus mesclado mais os termos específicos do seu site, que nenhuma lista genérica jamais conterá:

{
    "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    "OPTIONS": {"password_list_path": BASE_DIR / "config" / "common-passwords.txt.gz"},
},

Coloque nesse arquivo o nome da sua marca, o do seu produto, o seu domínio, e o ano corrente e o próximo. peticaobrasil2026 é uma senha que alguém vai escolher, não está na wordlist de ninguém, e é trivialmente adivinhável por qualquer um que tenha olhado a sua home.


A Visão do Analista

O vocabulário do CySA+ importa aqui, porque política de senhas costuma ser arquivada sob "conformidade" e não é ali que ela pertence. Uma pilha de validadores é um controle preventivo, e um incomum: quase todo controle preventivo desta série estreita o que um atacante pode fazer com a sua aplicação, enquanto este estreita o que os seus usuários podem colocar dentro dela. Tudo o mais que defende contra uma senha adivinhada — o rate limit do Post 11, o hashing por baixo da linha, o segundo fator na conta — só entra em cena depois que a conta já existe com um segredo ruim, o que os torna controles compensatórios para uma política fraca, não substitutos dela. Essa ordem importa quando lhe pedem para justificar esforço: throttling reduz a taxa de adivinhação, MFA reduz o valor de um palpite certo, e só o validador reduz o número de contas que valem a pena adivinhar. Defesa em profundidade significa ter os três, não escolher o que foi mais fácil de entregar.

A segunda metade do trabalho do analista aqui é medição, e é a parte que desenvolvedores quase nunca fazem. Você não precisa especular sobre quantos dos seus usuários escolheram algo vazado — você pode descobrir. Como hashes de senha são de mão única, você não pode testar as senhas dos seus usuários existentes contra uma lista diretamente, mas tem duas opções honestas: checar no próximo login bem-sucedido, quando você detém o texto puro por exatamente uma requisição e pode passá-lo pelo validador de vazamentos ali, sinalizando ou forçando rotação da conta; ou levar seu próprio dump de hashes para um ambiente isolado e quebrá-lo com o mesmo ferramental que um atacante usaria, o que lhe dá um percentual em vez de uma opinião. Qualquer um dos dois produz um número que você pode pôr na frente de quem decide, e o número costuma ser muito pior do que qualquer um espera. Isso, e não um documento de política, é o que faz um mínimo de 15 caracteres ser aprovado.


Detectando Automaticamente

Testando Sua Defesa

Três testes, e o segundo é o que ninguém escreve.

Prove que o caminho valida. Aponte-o para o endpoint que define senhas — cadastro, redefinição, aceite de convite, um serializer do DRF:

def test_registration_rejects_a_breached_password(self):
    response = self.client.post("/register/", {
        "username": "mallory", "password": "Password123!",
    })
    self.assertEqual(response.status_code, 400)
    self.assertFalse(User.objects.filter(username="mallory").exists())

def test_registration_accepts_a_strong_passphrase(self):
    response = self.client.post("/register/", {
        "username": "mallory", "password": "flat marble kettle horizon",
    })
    self.assertEqual(response.status_code, 201)

A segunda asserção do primeiro teste é a importante. Uma view pode retornar 400 por uma dúzia de razões; só o assertFalse(...exists()) prova que a senha nunca chegou ao banco.

Prove que a política ainda está lá. Este é o teste que pega o Padrão 2, e quase ninguém tem, porque ele faz asserção sobre settings e não sobre comportamento:

from django.conf import settings

def test_password_policy_is_configured(self):
    names = {v["NAME"].rsplit(".", 1)[-1] for v in settings.AUTH_PASSWORD_VALIDATORS}
    self.assertIn("MinimumLengthValidator", names)
    self.assertIn("CommonPasswordValidator", names)

    minimum = next(
        v for v in settings.AUTH_PASSWORD_VALIDATORS
        if v["NAME"].endswith("MinimumLengthValidator")
    )
    self.assertGreaterEqual(minimum.get("OPTIONS", {}).get("min_length", 8), 15)

Apague o bloco, baixe o piso ou digite errado o caminho de importação, e isto falha no CI em vez de em produção. Custa nove linhas, e é a única coisa entre você e uma refatoração de settings que desliga silenciosamente todos os validadores que você tem.

E a promessa da introdução. Eis a checagem de vazamento inteira, em quatro linhas que você pode colar num shell agora mesmo — sem chave de API, sem conta, e sua senha nunca deixa o processo:

import hashlib, urllib.request
h = hashlib.sha1(b"Password123!", usedforsecurity=False).hexdigest().upper()
body = urllib.request.urlopen("https://api.pwnedpasswords.com/range/" + h[:5]).read().decode()
print(next(int(l.split(":")[1]) for l in body.splitlines() if l.startswith(h[5:])))
# 295389

Rode contra a sua própria senha antes de fechar esta aba. Esse é o argumento inteiro deste post, e leva uns quatro segundos.

Fazendo a Varredura

O laboratório entrega três views de cadastro — uma que nunca valida, uma que valida inline, uma que valida em um Form — o que transforma "o scanner pega?" na pergunta mais afiada de se ele consegue distingui-las.

O Bandit erra a classe por completo. O Bandit percorre a AST do Python procurando construções arriscadas numeradas com B, e não há construção arriscada aqui: set_password() é a função correta, chamada corretamente, no objeto certo. O defeito é a ausência de uma chamada ao lado dela, e ausência não tem nó de AST. Seus quatro achados no laboratório são três ocorrências de senha embutida nas fixtures e um B310 no urlopen do validador seguro. Seu achado mais barulhento foi sobre a correção, não sobre a falha.

O Semgrep precisa de uma palavra sobre como é empacotado, porque esta seção inteira depende disso. As regras dele vêm de dois lugares, e você precisa pedir cada um separadamente. O --config p/<nome> puxa um pack curado — um conjunto escolhido a dedo que a equipe do Semgrep endossa, e é o que quase todo pipeline de CI e quase todo tutorial roda. O --config r/<nome> puxa do registro: toda regra publicada para aquela linguagem ou framework, incluindo as que os packs deixam de fora de propósito, entre elas uma subcategoria que o Semgrep rotula como audit. Mesmo motor, mesmo laboratório, conjunto de regras diferente. Para esta vulnerabilidade, essa diferença é o resultado inteiro.

Os packs curados do Semgrep também erram:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_13_weak_passwords/
# Ran 156 rules on 18 files: 3 findings.

Nenhum dos três é sobre validação de senha, e eis a parte que deveria incomodar você: dois deles disparam na view vulnerável e na segura de forma idêntica. Uma regra que não consegue distinguir o bug da correção não carrega sinal algum sobre esta vulnerabilidade, por mais séria que pareça sua etiqueta de severidade em um relatório.

A camada de auditoria pega:

semgrep scan --config r/python.django --config r/python labs/post_13_weak_passwords/
# ❯❱ python.django.security.audit.unvalidated-password
#    labs/post_13_weak_passwords/views_vulnerable.py:37   user.set_password(password)

Ela dispara na view vulnerável e fica silenciosa na segura inline — então este post entrega nenhuma regra personalizada. O achado não é "escreva uma detecção"; é "o pack curado que você está rodando exclui esta, então rode a camada de auditoria também".

Por que ele a exclui? Não é palpite — os próprios metadados publicados da regra dizem: subcategory: [audit], confidence: LOW. Os packs p/* descartam deliberadamente regras da subcategoria de auditoria, porque uma regra de auditoria é uma lista de lugares para ir ler, não uma lista de defeitos. O no-csrf-exempt do Post 8 e o avoid-mark-safe do Post 2 faltam no p/django pela mesma razão. Uma regra pode existir por anos, estar exatamente certa sobre a sua base de código, e nunca rodar uma vez sequer.

E a regra merece sua baixa confiança duas vezes. Ela também sinaliza a view baseada em Form do laboratório — o formato idiomático do Django, em que um método clean_password() valida e a view escreve depois — porque seus padrões de exclusão só procuram por validate_password() no mesmo escopo léxico que set_password(). Validação a uma chamada de função de distância é invisível para ela. Ou seja: quanto mais idiomático o seu Django, maior a chance de esta regra estar errada sobre ele.

E há o autofix, que é pior do que inútil. O Semgrep oferece:

if django.contrib.auth.password_validation.validate_password($X, user=$MODEL):
    $MODEL.set_password($X)

validate_password() retorna None em caso de sucesso e levanta exceção em caso de falha. Nunca retorna um valor verdadeiro, então aquele ramo jamais é tomado e set_password() jamais roda. Li isso três vezes convencido de que tinha entendido ao contrário, e então verifiquei no contêiner:

validate_password returns: None
  -> branch taken?            False
  -> u.password after autofix: ''
  -> u.has_usable_password(): True

Aplique essa correção e você não endureceu o cadastro — você parou de definir senhas, e como has_usable_password() só procura pelo prefixo ! que o Django usa para marcar uma senha como inutilizável, as contas nem sequer são sinalizadas como quebradas. São simplesmente contas em que ninguém jamais consegue entrar. Um autofix é uma sugestão de um casador de padrões que nunca rodou o seu código.

Uma última ausência que vale nomear, porque é o oposto do post anterior. A flag de cookie do Post 12 era pega pelo próprio manage.py check --deploy do Django como security.W012. Não há equivalente para política de senha: nada em django.core.checks.security ou em django.contrib.auth.checks olha para AUTH_PASSWORD_VALIDATORS, então uma lista vazia — o padrão real do Django — não produz aviso algum do próprio scanner de deploy do framework. O teste de settings de nove linhas acima é a verificação que o Django não entrega.

A saída capturada completa, o experimento de mutação que prova que a regra se ancora na chamada a validate_password() e não em algo incidental, e a reprodução do autofix quebrado estão todos no diretório scans/ do laboratório.


O incômodo deste post é o quanto dele é subtração. Não há um validador esperto no fim. Você apaga quatro dos meus, muda um 8 para 15, acrescenta uma checagem de blocklist, e então vai garantir que todo caminho que escreve uma senha de fato chame a coisa. Gastei mais esforço construindo uma política que parecia rigorosa do que teria gastado construindo uma que funciona. O que as separa não é sofisticação — é se a regra mede onde uma senha está na lista ordenada de um atacante, ou o quanto ela irritou quem a escolheu.

O próximo post entra pela outra porta da mesma conta. Redefinição de senha é um caminho de autenticação paralelo — quem a completa é dono da conta, sem senha nenhuma — e fluxos de redefinição feitos à mão reabrem todos os buracos que o gerador de tokens do Django foi desenhado para fechar: tokens que nunca expiram, tokens que sobrevivem à troca de senha que autorizaram, e links de redefinição cujo domínio um atacante escolhe com um cabeçalho Host.


Leitura Complementar

← Voltar para a série