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: ~27 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 umForm, e recusam essa senha de imediato. Reproduza tudo comcurl, 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.
E há a parte 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, contra um hash rápido e sem salt, hardware moderno percorre isso em minutos.
Password123! mostra como isso funciona. 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 — a maiúscula na primeira letra, os dígitos no fim, o símbolo depois dos dígitos —, todas elas presentes por padrão em qualquer conjunto de regras de cracking, e o resultado já apareceu 295.389 vezes em vazamentos. Isso a coloca perto do topo de todo corpus real, entre as primeiras candidatas que qualquer atacante tenta.
Contra um caminho de cadastro que nunca valida, o ataque leva uma requisição. A vítima se cadastrou por ele — um fluxo de onboarding, um convite, um cadastro por API que faz o hash da senha com set_password() e nunca consulta política nenhuma — e escolheu Password123!, e nada a recusou. O atacante envia uma requisição de login com o nome de usuário da vítima e essa senha, e entra. Não há wordlist para percorrer nem throttle para vencer: a conta cai porque a senha é barata, não porque a adivinhação é rápida. Rate limiting e bloqueio de conta (Post 11) retardam um atacante que precisa de milhares de tentativas; não têm nada a dizer sobre a primeira.
O validador de senha é o único controle que age antes de 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.
Uma senha fraca também é explorada de outras formas, e a política falha em outros pontos. O cracking offline roda contra os hashes de um banco roubado, na velocidade que o algoritmo de hash e as GPUs do atacante permitirem, onde nenhum throttle de login alcança; o armazenamento de senhas ganha um post próprio mais adiante na série. O credential stuffing pula a adivinhação por completo e repete um par colhido do vazamento de outra pessoa, que é onde senhas reutilizadas mordem e onde a autenticação multifator vira a resposta. Do lado da política, o bloco de validadores pode ser apagado ou diluído nos settings — validadores removidos, o piso baixado ou um caminho de importação digitado errado, que o Django só acusa quando uma senha é validada pela primeira vez ou um formulário de senha é renderizado, porque carrega os validadores de forma preguiçosa. O validate_password() pode ser chamado sem o usuário, o que desliga em silêncio o UserAttributeSimilarityValidator (seu validate() abre com if not user: return), ou depois da escrita, quando a senha fraca já está gravada. E regras de composição — uma maiúscula, um dígito, um símbolo — empurram os usuários para o canto previsível onde mora o Password123!, e é por isso que a NIST agora as proíbe.
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. Continua sendo a linha um da lista de 19.640 entradas que vem dentro do Django hoje, uma lista que o Django credita a Royce Williams: um recorte das 20 mil primeiras do corpus do Have I Been Pwned, compilado mais de uma década depois da RockYou.
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. Os usuários da própria RockYou não precisaram de cracking — pular o hashing não deixou nada para quebrar —, mas a lista em que as senhas deles se transformaram é o primeiro arquivo de candidatas que as ferramentas de cracking usam contra hashes roubados em qualquer outro lugar. 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.
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, segundo a acusação, 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 anunciado em 27 de março de 2012 — sujeito a homologação judicial, sem admissão de responsabilidade — 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 acusação alegava 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.
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.
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. As contagens de vazamento vêm de quatro linhas que você pode colar num shell agora mesmo — sem chave de API, sem conta, e a 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.
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()chamamake_password(password)diretamente. Sem validação.- Qualquer serializer do DRF,
Formpersonalizado, mutation GraphQL, management command ou action do admin que defina uma senha por conta própria. createsuperuser --noinput. O comando chamavalidate_password()— mas apenas dentro do seu ramoif options["interactive"]:. O caminho não interativo lêDJANGO_SUPERUSER_PASSWORDdo ambiente e escreve direto. E mesmo interativamente, uma validação que falha imprime os erros e então perguntaBypass 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.
O Padrão Vulnerável e a Correção
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. A view vulnerável do laboratório faz o hash da senha e nunca a valida; a view segura acrescenta uma chamada, no lugar certo, com os dois argumentos:
def register(request):
...
user = User(username=username, email=request.POST.get("email", ""))
- # DANGER: hashes it, does not validate it. No consultation of
- # AUTH_PASSWORD_VALIDATORS happens anywhere in this request.
+ try:
+ # Before the write, with the (still unsaved) user.
+ validate_password(password, user, password_validators=get_policy())
+ except ValidationError as exc:
+ return HttpResponse(
+ "password rejected:\n" + "".join(f" - {m}\n" for m in exc.messages),
+ status=400,
+ )
user.set_password(password)
user.save()
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. Três detalhes da correção 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, com exc.messages entregando as mesmas strings traduzidas que os formulários embutidos mostram. As duas views estão no laboratório: views_vulnerable.py e views_secure.py.
A resposta melhor, onde couber, é não escrever isso: herde de BaseUserCreationForm ou SetPasswordForm e deixe o mixin fazer o trabalho (views_form.py é o mesmo formato com um Form simples). 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. O terceiro argumento é uma escolha do próprio laboratório, e vale conhecer: password_validators= recebe uma lista explícita, construída por get_password_validators() a partir dos mesmos dicionários {"NAME": ..., "OPTIONS": ...}, e ignora o AUTH_PASSWORD_VALIDATORS global. Sem ele, o piso de 15 caracteres do laboratório teria alcançado todos os outros laboratórios do repositório, vários dos quais usam senhas fracas deliberadamente. Em um projeto, o lugar da política é o setting.
Padrão 2 — Os quatro padrões, que aceitam Password123!
Chamar o validate_password() é metade da correção. A outra metade é contra o que ele valida, porque o bloco que o startproject escreve também aceita a senha da vítima — a tabela em Proteções Padrão do Django a mostrou passando pelos quatro. As views do laboratório que validam usam a política de policy.py, e num projeto ela substitui aquele bloco no settings.py:
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
- {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ "OPTIONS": {"min_length": 15},
+ },
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
- {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+ {"NAME": "labs.post_13_weak_passwords.validators.BreachListValidator"},
]
Quinze é o piso da NIST para fator único, e 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. O NumericPasswordValidator é o único padrão que a política deixa de fora: com quinze caracteres, as datas e os números de telefone para os quais ele foi escrito já são curtos demais, e o que ele ainda recusa — uma sequência longa de dígitos — ele recusa só pela classe de caractere, o que é uma regra de composição com outro nome.
O que levanta a questão prática de onde o usuário deveria conseguir uma. Escrevi um gerador de frases-senha para este site: ele sorteia palavras uniformemente ao acaso de uma lista pública — inglês, português ou ambos — e mostra a entropia que de fato produziu, em vez de uma barrinha verde. O que importa para este post é onde ele roda. Toda frase-senha é gerada no navegador pelo crypto.getRandomValues() e nenhuma requisição carrega o resultado, porque a alternativa falha no critério que este post passa o tempo todo aplicando aos outros. Uma frase-senha que chega ao meu servidor passa por código e infraestrutura cujos logs eu teria então de garantir, camada por camada, enquanto a ferramenta existir — e, colocada numa URL, ganha uma cópia no log de roteamento do Heroku, que registra todo caminho com sua query string. "Nunca salvo" não é uma promessa que alguém possa fazer sobre um valor que atravessou a rede; é uma que se pode fazer sobre um valor que nunca saiu da aba. A diferença é visível na sua própria aba de rede, que é a única boa razão para acreditar em mim.
Não coloque um teto baixo. A NIST pede que verificadores permitam ao menos 64 caracteres — e chama de razoável algum limite, porque uma senha de um megabyte custa tempo real para hashear —, e o armazenamento do Django não cobra nada para cumprir isso: 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 — o BCryptPasswordHasher entrega a senha crua ao bcrypt, que só lê 72 bytes: versões do bcrypt anteriores à 5.0.0 (setembro de 2025) truncam o resto silenciosamente, então uma passphrase de 100 caracteres vira só 72 bytes de segredo, e da 5.0.0 em diante ele levanta ValueError, o que transforma a passphrase longa em um erro de servidor. 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.
A lista de vazamentos
A blocklist é a parte que a NIST exige. 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 acontece localmente. O servidor aprende em qual de aproximadamente um milhão de baldes sua senha caiu, e nada mais. A consulta de quatro linhas em Proteções Padrão do Django é o mecanismo inteiro; o validators.py do laboratório o embrulha no PwnedPasswordValidator, só com a biblioteca padrão, então ele se encaixa em qualquer projeto. O SHA-1 ali é o formato que a API exige, uma chave de busca e não uma proteção, e o usedforsecurity=False diz isso. Ele envia Add-Padding: true, para que o tamanho da resposta não estreite qual balde foi pedido, e falha aberto por padrão, para que uma queda do HIBP não trave todo cadastro e toda redefinição do seu site — uma escolha a fazer deliberadamente, e a errada para um banco. O seu threshold pode deixar passar senhas raras, e a NIST pende para esse lado, ao considerar de pouco benefício uma blocklist excessivamente grande. E toda a propriedade de segurança está no prefixo de cinco caracteres: uma implementação que envia o hash inteiro não é uma checagem de vazamento, e sim um endpoint de divulgação de senha com um nome simpático.
A política ativa do laboratório usa a metade offline, o BreachListValidator, uma lista gzipada que ele controla, para que seus testes nunca dependam de um terceiro estar no ar; o próprio CommonPasswordValidator do Django faz o mesmo com a opção password_list_path. 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.
Para o Petição Brasil a conclusão é menos arrumada. Quatro dos meus sete validadores personalizados são exatamente o que a norma proíbe, então esses têm de sair. O resto desta política — 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 num post que argumenta que esse defeito é entregue como dado que ninguém nunca olha.
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 da senha armazenada, o segundo fator na conta — só entra em cena depois que a conta já existe com um segredo ruim, o que os torna camadas adicionais atrás de 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
O teste discriminante é o do laboratório: envie a senha da vítima para a view de cadastro segura e afirme que ela é recusada e nunca gravada.
def test_secure_register_rejects_the_same_password(self):
resp = self.client.post(
"/passwords/secure/register/",
{"username": "mallory", "password": VICTIM_PASSWORD},
)
self.assertEqual(resp.status_code, 400)
self.assertFalse(User.objects.filter(username="mallory").exists())
body = resp.content.decode()
# Rejected twice over: below the 15-character floor AND on the
# breach blocklist. Neither reason is a composition rule.
self.assertIn("at least 15 characters", body)
self.assertIn("known data breach", body)
Aponte-o para /passwords/vulnerable/register/ e ele falha: a view responde 201 e mallory existe. A linha do assertFalse(...exists()) é a importante — uma view pode retornar 400 por uma dúzia de razões, e só essa asserção prova que a senha nunca chegou ao banco. O tests.py do laboratório também verifica que uma passphrase forte é aceita, então uma view que recusasse todo mundo não passaria, e, à parte, roda o login de um palpite só que captura a flag de uma vítima criada pelo seed — o impacto, não o caminho de cadastro.
A segunda verificação é de outro tipo — ela faz asserção sobre a configuração, não sobre uma view, e é a que impede que a política do Padrão 2 suma em silêncio:
from django.contrib.auth.password_validation import (
CommonPasswordValidator, MinimumLengthValidator, get_default_password_validators,
)
def test_password_policy_is_configured(self):
# Instantiating them is what Django does on the first validation.
validators = get_default_password_validators()
self.assertTrue(any(isinstance(v, CommonPasswordValidator) for v in validators))
floors = [v.min_length for v in validators if isinstance(v, MinimumLengthValidator)]
self.assertTrue(floors and min(floors) >= 15)
Apague o bloco ou baixe o piso, e isto falha no CI em vez de em produção.
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 um dado pack deixa de fora, muitas delas numa 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.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"; é "os packs que você está rodando não trazem esta, então rode a camada de auditoria também".
Por que ela falta? Os packs não dizem. Os próprios metadados da regra — subcategory: [audit], confidence: LOW — a marcam como uma lista de lugares para ir ler, não uma lista de defeitos, e os três packs trazem comparativamente poucas regras assim (2 das 28 do p/django). O pack feito para elas é o p/security-audit, e ele traz esta: rodado contra o laboratório, dispara na view vulnerável e fica silencioso na segura inline, exatamente como a varredura do registro. O no-csrf-exempt do Post 8 e o avoid-mark-safe do Post 2, ambos ausentes do p/django, também estão nele. 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. O próprio manage.py check --deploy do Django avisa sobre uma longa lista de settings — um cookie de sessão sem Secure é o security.W012 —, mas 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 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 hábito que este post me deixou: todo caminho de código que escreve uma senha responde a uma pergunta — onde está o validate_password(password, user), e ele roda antes da escrita? — e a política que ele roda mede onde uma senha está na lista ordenada de um atacante, não 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 um fluxo de redefinição feito à mão reabre os buracos que o gerador de tokens do Django foi desenhado para fechar: tokens que nunca expiram e tokens que sobrevivem à troca de senha que autorizaram.
Leitura Complementar
- Documentação do Django — Password validation — o setting
AUTH_PASSWORD_VALIDATORS, os quatro embutidos e como escrever o seu. - NIST SP 800-63B-4 — Digital Identity Guidelines: Authentication and Authenticator Management (julho de 2025) — a §3.1.1.2 é a seção de senhas: o mínimo de 15 caracteres, a proibição de regras de composição, a exigência de blocklist e o fim da rotação forçada.
- Have I Been Pwned — Pwned Passwords API — o endpoint de range com k-anonimato e o cabeçalho
Add-Padding. - FTC — Charges That Security Flaws in RockYou Game Site Exposed 32 Million Email Addresses and Passwords (2012) — a ação de execução por trás da wordlist.
- CWE-521 — Weak Password Requirements e OWASP A07:2025 — Authentication Failures.
- django-security-lab — Lab 13: Weak Passwords & Validators — o laboratório executável, suas três views de cadastro, os validadores de lista de vazamento e a saída capturada de Bandit/Semgrep em
scans/.