Autenticação Multifator: Por Que @login_required É a Pergunta Errada Depois Que Você Tem um Segundo Fator

Autenticação Multifator: Por Que @login_required É a Pergunta Errada Depois Que Você Tem um Segundo Fator

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

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: uma vítima que de fato se cadastrou no MFA, um dispositivo TOTP confirmado e dois dashboards cujo código é idêntico abaixo de um decorador. Entre só com a senha dela e um deles entrega a flag. Depois conclua o segundo fator com um código que você gera em quatro linhas de biblioteca padrão, e veja o outro abrir direito.

O Petição Brasil não tem MFA. Fui procurar antes de escrever isto, esperando achar algo meio pronto, e não achei nada — o único is_verified() da base de código pertence a uma assinatura, não a um usuário.

O que existe é todo o resto: um contador de bloqueio por falhas de login, limitação de taxa por view, Turnstile na frente dos formulários, um backend de autenticação próprio. Alguém dedicou tempo de verdade àquele login. O que ele guarda, porém, é mais estreito do que parece: uma senha do Petição Brasil permite criar e publicar uma petição, não assiná-la — a assinatura passa pela PKI do gov.br, e é o certificado que a torna vinculante. A conta é uma identidade de publicação, não uma chave de assinatura, e julguei esse impacto baixo o suficiente para viver sem um segundo fator.

Então este não é um post que eu escreva da posição de quem já resolveu. É aquele em que precisei descobrir o que "adicionar MFA" significa de fato, para além de instalar um pacote — e a resposta acabou sendo quase inteiramente sobre uma pergunta que eu nunca havia pensado em fazer.

A pergunta é: o que você está verificando? Todo desenvolvedor Django conhece o @login_required. Ele pergunta se a requisição está autenticada. Assim que você acrescenta um segundo fator existe uma segunda pergunta, diferente — se esta sessão apresentou aquele fator — e as duas não são a mesma pergunta, não têm a mesma resposta e não são verificadas pelo mesmo decorador. O bug do InvenTree, mais adiante neste post, se resume a isso: um caminho da aplicação fazendo a primeira pergunta quando precisava fazer a segunda.


O Ataque: O Que É e Como Funciona

O modelo mental útil é que o MFA não é uma propriedade de uma conta. É uma propriedade de uma sessão, e toda rota capaz de emitir uma sessão é um lugar onde essa propriedade pode sumir.

Pense em como um segundo fator é de fato acrescentado a uma aplicação existente. Você tem uma view de login. Você adiciona um passo: depois que a senha confere, peça um código, verifique-o e só então conclua. Esse fluxo costuma ficar correto, porque é justamente o que você estava olhando enquanto pensava em MFA. Só que, em qualquer aplicação com algum tempo de estrada, a tela de login não é o único lugar onde nasce uma sessão.

Tem a troca de token do app mobile. O callback de SSO. O botão "entrar como este cliente" da ferramenta de suporte. O formulário legado para o qual a build antiga de iOS ainda envia dados, e que ninguém consegue apagar enquanto a build antiga existir. Uma redefinição de senha que já deixa o usuário logado ao concluir, o que é um detalhe gentil e também um caminho de autenticação completo (é o território do Post 14, Fluxos de Redefinição de Senha, chegando aqui). Cada um deles termina numa chamada a login(), e cada um foi escrito antes de o MFA existir, por alguém resolvendo outro problema. Nenhum sabe que agora há uma segunda pergunta a fazer.

Essa é a primeira metade da classe. A segunda é mais sutil e sobrevive até a uma implantação cuidadosa: a verificação na própria view sensível nunca é trocada. Você acrescentou o segundo fator na porta da frente e deixou toda porta interna verificando o que sempre verificou. O @login_required está na view sensível porque o @login_required está na view sensível desde 2019. Ele pergunta se você está logado. Você está. Entrou.

As duas metades produzem o mesmo observável: uma sessão que tem uma senha e mais nada, alcançando algo que deveria exigir dois fatores. O lado do atacante é quase ofensivamente sem graça. Ele precisa da senha — de um corpus de vazamento, de um phishing, de credential stuffing (Post 11), de uma escolha fraca que seus validadores permitiram (Post 13) — e depois precisa achar a única rota da sua aplicação que não recebeu o recado. Não há payload. Não há truque esperto de protocolo. Ele faz login.

Vale deixar claro o que esta classe não é, porque o jeito mais comum de falar de MFA aponta para o alvo errado. A maior parte do conteúdo sobre MFA trata de atacar o fator em si: SIM swap para roubar um código por SMS, MFA fatigue, em que o atacante dispara notificações push às três da manhã até alguém tocar em aprovar, proxies de phishing em tempo real como o Evilginx, que repassam o código no instante em que a vítima o digita. Isso é real e vale conhecer. Também não é, em geral, um bug seu — são ataques ao canal ou à pessoa, e sua aplicação teria se comportado corretamente em todos eles. Forçar o código de seis dígitos na base da tentativa nem entra nessa lista no Django: o TOTPDevice.verify_token() do django-otp impõe um bloqueio crescente desde o primeiro código errado (1, 2, 4, 8… segundos, guardado na linha do dispositivo) e, durante o bloqueio, recusa até o código correto.

A falha de imposição é diferente, e é pior num aspecto específico: é a única da lista em que o segundo fator que o usuário cadastrou nunca chega a entrar em jogo. Ninguém é derrotado, enganado ou repassado. O código na tela do celular, renovando a cada trinta segundos, simplesmente nunca é pedido. Do lado do usuário é invisível — ele se cadastrou, vê o prompt toda manhã na aplicação web e tem todos os motivos para acreditar que está protegido. Essa distância entre o que se pediu ao usuário e o que a aplicação de fato exige é o que acho mais difícil de engolir nesta classe.


Incidentes Reais

InvenTree — "API bypasses MFA Requirements", GHSA-2crp-q9pc-457j, CVSS 7.4 (publicado em 24 de maio de 2024; corrigido nas versões 0.14.6, 0.15.2 e 0.16.0)

O InvenTree é um sistema open-source de gestão de estoque, e é um projeto Django, o que faz deste o caso mais parecido com um espelho que a série encontrou. Em 2024 ele estava construindo um novo front-end em React, e esse front-end autenticava contra /api/auth/login/. O comunicado descreve o que aquele endpoint fazia em uma frase: permitia login com usuário e senha válidos "even if MFA is configured".

Leia o escopo com atenção, porque é a parte que se generaliza. O bypass valia tanto para a configuração de MFA por usuário quanto para a política imposta globalmente — aquela que um administrador liga precisamente para que usuários individuais não possam optar por sair. Uma organização que havia exigido MFA em todas as contas passou a ter, a partir do lançamento da nova interface, um endpoint que não honrava a exigência. O login web que eles testaram continuava pedindo o código. A API, não.

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:L — 7,4, alto. O PR:L — Privileges Required: Low, ou seja, o atacante já precisa ter os privilégios de um usuário comum antes de explorar a falha — está correto aqui: é preciso ter a senha. Esse é todo o ponto da classe, e é por isso que esses comunicados pontuam mais baixo do que parecem. O MFA é a camada atrás da senha; um bypass não entrega a conta, ele remove aquilo que deveria salvar você depois que a senha já se foi.

Os mantenedores não conseguiram fazer aquele endpoint impor MFA rapidamente, então o remédio temporário foi bloquear por completo o acesso ao endpoint da API para usuários com MFA configurado — o que desabilitou a nova interface React justamente para quem seguiu a recomendação de segurança. Quem se cadastrou foi quem perdeu o recurso. Não digo isso como crítica; entre "usuários de MFA podem ser contornados" e "usuários de MFA ficam sem a nova UI por um tempo", eles escolheram certo. Mas é uma ilustração bem concreta do custo do retrofit quando os caminhos de autenticação se multiplicam mais rápido que a imposição ao redor deles.

O MITRE ATT&CK mapeia isso como T1078 — Valid Accounts: o adversário autentica com credenciais legítimas e, do ponto de vista do sistema, não faz nada anômalo. Note o que o mapeamento não captura. T1078 é a técnica idêntica tanto para um alvo com MFA quebrado quanto para um alvo sem MFA nenhum, porque o comportamento do adversário é o mesmo nos dois casos — ele faz login com uma senha que funciona. O framework não consegue distinguir essas duas aplicações de fora, e os seus logs também não. Esse é o formato do problema, não uma lacuna do ATT&CK.

O ângulo regulatório é menos uma regra nova do que uma linha de base que sobe. O Art. 46 da LGPD fala em medidas "aptas a proteger os dados pessoais", e o §1 mede isso contra "o estado atual da tecnologia". Esse estado mudou: para um sistema que trata dados pessoais, num ano em que toda grande plataforma entrega MFA de graça, "nós tínhamos senhas" é uma frase cada vez mais difícil de dizer a um regulador. Pior, o modo de falha aqui não é um controle ausente, é um controle que a organização acredita ter. Se a sua política de privacidade, a sua página de segurança ou o questionário do seu cliente corporativo diz que a autenticação multifator é imposta, e um endpoint não a impõe, a exposição não é meramente técnica. A FTC usou contra o RockYou a própria política de privacidade dele (Post 13, Senhas Fracas e Validadores); a ANPD estaria lendo o seu relatório de impacto.

Fontes: InvenTree — GHSA-2crp-q9pc-457j: API bypasses MFA Requirements (24 de maio de 2024)


Proteções Padrão do Django

O Django não entrega MFA. É daí que este post parte e, diferente da maioria dos posts desta série, aqui não há um padrão do framework silenciosamente salvando você — o django.contrib.auth conhece senhas e sessões, e para por aí.

O que o ecossistema oferece é o django-otp, e vale entendê-lo como três peças em vez de como um pacote, porque a vulnerabilidade mora nas costuras entre elas.

A primeira peça são os models de dispositivo. Uma linha TOTPDevice pertence a um usuário e guarda os dados a partir dos quais o código é calculado: o segredo compartilhado (uma chave gerada no cadastro, copiada para o app autenticador do usuário, normalmente lendo um QR code, e guardada no servidor), o passo (quantos segundos cada código vale; 30 por padrão), a quantidade de dígitos (6 ou 8; 6 por padrão) e o drift (quantos passos o relógio do celular está adiantado ou atrasado em relação ao do servidor; o django-otp aprende esse valor sempre que aceita um código de uma janela vizinha). Cadastrar-se significa criar uma e confirmá-la. Essa é a parte que todo mundo acerta, porque é a parte que tem interface.

A segunda é o OTPMiddleware, que precisa ser instalado depois do AuthenticationMiddleware e que a própria docstring descreve como cumprindo função análoga:

Just as AuthenticationMiddleware populates request.user based on session data, OTPMiddleware populates request.user.otp_device to the Device object that has verified the user, or None if the user has not been verified. As a convenience, this also installs user.is_verified(), which returns True if user.otp_device is not None.

Em tradução livre: assim como o AuthenticationMiddleware preenche request.user com base nos dados da sessão, o OTPMiddleware preenche request.user.otp_device com o objeto Device que verificou o usuário, ou None se o usuário não foi verificado. Por conveniência, ele também instala user.is_verified(), que retorna True se user.otp_device não for None.

Essa frase é o post inteiro. Há dois fatos independentes na requisição — quem você é e o que você provou — e o único trabalho do middleware é tornar o segundo respondível.

A terceira é o django_otp.login(request, device), que é o que de fato registra um dispositivo verificado na sessão. Conferir um código não faz isso. Verificar um token e esquecer de chamá-lo produz uma aplicação que pede um código, diz que ele estava certo e depois se comporta como se nada tivesse sido pedido.

E aí vem a armadilha. is_authenticated e is_verified parecem a mesma coisa, e não são. is_authenticated é uma propriedade: basta lê-la para obter True ou False. is_verified é uma função que o middleware instala no usuário: é preciso chamá-la, com parênteses, para obter True ou False. Sem os parênteses, você deixa de fazer a pergunta. Passa a testar a própria função, e em Python uma função, como quase qualquer objeto, conta como verdadeira num if:

if user.is_authenticated:      # correto — propriedade, já é True ou False
if user.is_verified:           # SEMPRE VERDADEIRO — testa a função, nunca a chama
if user.is_verified():         # correto — chama a função e obtém True ou False

Um par de parênteses ausente ali produz uma verificação de MFA que passa para todo mundo, para sempre, em silêncio. O Django já teve essa mesma armadilha com o is_authenticated, que também era um método. O Django 1.10 o transformou em propriedade, e as notas da versão dão o motivo: a mudança "avoids accidental information leakage if you forget to call the method" (evita vazamento acidental de informação se você esquecer de chamar o método). O is_verified do django-otp nunca passou por essa mudança. Eu não sabia que o is_authenticated havia mudado de método para propriedade até ir investigar por que uma regra do Semgrep disparava em código correto, o que é um caminho ligeiramente constrangedor até o conhecimento, mas eficaz.


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

Reduza a view vulnerável do laboratório ao que importa e ela tem quatro linhas, uma das quais é o bug:

# INSEGURO — não use em produção
@login_required
def dashboard(request):
    rows = SensitiveRecord.objects.filter(owner=request.user)
    return HttpResponse(render_rows(rows))

Não há mais nada de errado nela. O queryset só traz os registros do próprio usuário (sem IDOR — Post 6), a saída é escapada (sem XSS — Post 2) e o usuário está autenticado. Uma revisão de código faria as perguntas de sempre, e todas teriam boa resposta. A única que ninguém faz é se estar autenticado basta para esta view.

O que a torna explorável está em outro lugar — um segundo caminho até uma sessão:

# INSEGURO em um projeto que tem MFA — este endpoint nunca ouviu falar dele
@require_POST
def login_view(request):
    user = authenticate(request, username=..., password=...)
    if user is None:
        return HttpResponse("invalid credentials", status=401)
    login(request, user)              # uma sessão completa, um fator
    return HttpResponse(f"logged in as {user.username}")

Nada nessa view está errado como escrita. Ela estava correta no dia em que foi mesclada. Virou vulnerabilidade no dia em que alguém acrescentou um segundo fator em outro arquivo, e nenhum compilador, linter ou scanner vai te contar isso sozinho.

A terceira forma é a que mais me assusta, porque parece diligência. Alguém pediu o código. Alguém conferiu o código. Alguém recusou os errados. Mas o resultado da conferência nunca foi gravado na sessão, e o redirect que vem em seguida é uma requisição nova:

# INSEGURO — o código é conferido e depois jogado fora
if device.verify_token(request.POST["code"]):
    return redirect("dashboard")      # nada registrado na sessão

O verify_token() só responde se o código está certo; ele não grava nada na sessão. Quem grava é o django_otp.login(request, device), que guarda ali o id do dispositivo. Na requisição seguinte, o OTPMiddleware procura esse id, não acha, e is_verified() volta a ser False. O que acontece depois depende do decorador que protege o dashboard. Com @otp_required, o usuário legítimo digita um código correto e é mandado de volta para a tela de login, toda vez: ninguém entra. Com @login_required, o dashboard nunca pergunta se algum código foi verificado, então o passo de MFA é decorativo: quem tem só a senha entra direto. Os dois desfechos são ruins. Só o primeiro gera chamado no suporte; o segundo falha em silêncio.


Implementação Segura: O Jeito Django

A view corrigida é a mesma view:

# SEGURO — padrão recomendado
from django_otp.decorators import otp_required

@otp_required
def dashboard(request):
    rows = SensitiveRecord.objects.filter(owner=request.user)
    return HttpResponse(render_rows(rows))

No laboratório, os dois arquivos são idênticos abaixo do decorador, tirando um comentário # DANGER: do lado vulnerável marcando onde está o bug. Compare os dois. A vulnerabilidade inteira, e a correção inteira, é qual pergunta a porta faz.

O passo de verificação tem exatamente uma linha que sustenta tudo, e não é a que confere o código:

# SEGURO
if not device.verify_token(request.POST.get("code", "")):
    return HttpResponse("bad code", status=401)

otp_login(request, device)     # <- é isto que torna is_verified() verdadeiro

Além dessas duas mudanças, o trabalho é inventário, não código, e prefiro dizer isso a fingir que existe uma correção inteligente. Ache todo caminho que chega em login() e decida, caminho a caminho, se ele pode emitir uma sessão plenamente verificada. Depois ache toda view que serve algo digno de um segundo fator e tire-a do @login_required. Se a área sensível é uma seção coerente do site, proteja-a de uma vez com middleware ou um mixin numa classe base, em vez de decorar view por view — um decorador que você precisa lembrar de acrescentar é um decorador que alguém vai esquecer. É o mesmo argumento de escopar um queryset num manager em vez de em cada view.

Um julgamento sobre o qual realmente fiquei em cima do muro: impor MFA na sessão ou re-perguntar na ação. Nível de sessão é o que o @otp_required oferece e o que este post ensina, e é o padrão certo. Mas uma sessão verificada às 9h continua verificada às 18h num notebook destravado, e para uma operação genuinamente perigosa — alterar dados de repasse, exportar a tabela de clientes — o controle honesto é perguntar de novo naquele momento. O Django não tem nada embutido para isso (o django-otp não entrega uma verificação de "verificado recentemente"), então é código que você escreve. A linha de base do NIST para AAL2 é um piso útil — reautenticar ao menos a cada 24 horas, e depois de uma hora ocioso (SP 800-63B-4 §2.2.3) —, mas ela não diz quais ações merecem uma nova pergunta. Não me decidi sobre onde fica a linha, e desconfio que quem te disser ter uma regra limpa para isso não a testou com uma equipe de suporte.


A Visão do Analista

O MFA não faz nada por você enquanto a senha não vaza. Essa é a descrição do cargo dele, não um defeito: é um controle preventivo posto atrás de outro controle preventivo, para que uma falha não seja a defesa inteira. (O CySA+ e o NIST usam "controle compensatório" para outra coisa: um controle adotado no lugar de outro que não pode ser implementado.) A razão de existir do MFA, inteira, é ser o que continua de pé depois que a senha já caiu: por força bruta ou credential stuffing (Post 11, Força Bruta e Credential Stuffing), por ser fraca o bastante para ser adivinhada (Post 13, Senhas Fracas e Validadores) ou num vazamento de outro site em que o usuário reutilizou a senha.

O que leva ao ponto operacional que vale carregar: um segundo fator que não é imposto em todos os caminhos não é um controle parcialmente eficaz, é um controle ausente. O atacante não usa a porta que você defendeu. Não existe média aqui — cobertura é um mínimo, não uma média — e é por isso que "nós temos MFA" não é uma afirmação respondível numa revisão de controles. A versão respondível nomeia os caminhos.

O problema de detecção tem o mesmo formato do Post 14 (Fluxos de Redefinição de Senha), em que tomar uma conta pelo reset deixa nos logs o mesmo rastro de um usuário legítimo que esqueceu a senha. Aqui é pior: nem há um reset nos logs, só um login. A sessão do atacante parece exatamente uma sessão legítima, porque é uma sessão legítima — senha correta, user agent normal, horário plausível. Seus logs registram um login bem-sucedido. A única telemetria que distinguiria as duas é um registro de quais fatores foram apresentados, e a maioria das aplicações não registra isso porque nunca teve motivo. Se você levar um hábito operacional deste post, que seja esse: registre o estado de verificação junto do evento de autenticação. Custa um campo, e sem ele você não consegue responder "o MFA foi mesmo usado?" depois de um incidente — que é a primeira pergunta que alguém vai fazer.


Detectando Automaticamente

Testando Sua Defesa

Nenhum scanner pronto encontra esta classe (veja abaixo), então a primeira verificação é um teste. O formato útil é uma varredura, não uma asserção por view:

def test_no_protected_url_is_reachable_with_a_password_only_session(self):
    protected = ["/billing/", "/exports/", "/admin-tools/"]      # sua lista
    login_paths = [                                               # toda rota que chama login()
        ("/accounts/login/", {"username": "erin", "password": PASSWORD}),
        ("/api/auth/login/", {"username": "erin", "password": PASSWORD}),
    ]
    for path, data in login_paths:
        with self.subTest(login_path=path):
            attacker = Client()
            attacker.post(path, data)
            reachable = [u for u in protected if attacker.get(u).status_code == 200]
            self.assertEqual(reachable, [])

Dirija uma sessão que só apresentou senha — por cada rota capaz de emitir uma — a cada URL que deveria estar atrás do segundo fator, e colete as que respondem 200. São poucas linhas, e ela só teria pegado o bug do InvenTree com /api/auth/login/ na lista de caminhos de login, o que é o inventário de novo.

Duas coisas que vale dizer com todas as letras. As duas listas são mantidas à mão, então uma view ou uma rota de login acrescentada no trimestre que vem não fica coberta até alguém adicioná-la — isso é uma fraqueza real e não tenho boa resposta para ela além de manter as listas ao lado do URLconf, onde quem revisa veja tudo junto. E cada sessão precisa vir pela sua rota real. Para as portas, o force_login() dá a mesma sessão não verificada, mas os caminhos são a metade da classe em que o InvenTree falhou, e só dirigir cada um mostra o que ele de fato emite.

Depois, a verificação que ninguém roda, que leva um comando:

semgrep scan --config r/python.lang.maintainability.is-function-without-parentheses .

Ela sinaliza todo atributo is_* lido sem chamada. No Django moderno isso inclui is_authenticated, que é propriedade, então espere ruído ali; todo acerto em is_verified é real. Se a sua base de código tem is_verified sem parênteses em algum lugar, aquilo é uma verificação de MFA que nunca retornou False uma única vez.

Fazendo a Varredura

O Bandit percorre a AST do Python atrás de construções arriscadas numeradas com B. Contra este laboratório ele produz exatamente uma:

bandit -r labs/post_15_mfa/
# >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'copper meadow transit fifty'
#    Location: labs/post_15_mfa/seed.py:33:18

Uma senha de fixture, o que um laboratório que precisa entregar a senha ao atacante não tem bem como evitar. Tudo bem. Quatro linhas abaixo, no mesmo arquivo, está isto, e ninguém diz uma palavra a respeito:

TOTP_KEY = "3132333435363738393031323334353637383930"

Aquilo é o segundo fator. Um segredo compartilhado que, se vazar, gera códigos válidos para sempre, e que ninguém rotaciona do jeito que rotaciona uma senha. O B105 casa pelo nome da variável, contra uma lista de palavras parecidas com "password". VICTIM_PASSWORD contém "password". TOTP_KEY não, então é só uma string. Uma ferramenta que acha credenciais perguntando como elas se chamam perde toda credencial cujo nome não está na lista dela — "key" não está na do B105, "secret" está. Chame a mesma constante de TOTP_SECRET e o Bandit a reporta.

Essa é a coisa mais interessante que qualquer scanner fez aqui, e repare que não tem nada a ver com a vulnerabilidade.

Os pacotes curados do Semgrep (p/...) têm menos que isso a dizer:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_15_mfa/
# Ran 180 rules on 18 files: 0 findings.

Zero. Nem um falso positivo para triar, nem um quase-acerto. Um projeto Django com MFA instalado, um dashboard corretamente protegido e outro escancarado, e 180 regras não têm nada a dizer.

Todas as regras do registro para Python e Django (r/...), que incluem as de auditoria, mais ruidosas, devolvem nove achados:

semgrep scan --config r/python.django --config r/python labs/post_15_mfa/
# Ran 372 rules on 18 files: 9 findings.

Nenhum é sobre o decorador que protege o dashboard. Os dois dashboards recebem os mesmos dois direct-use-of-httpresponse cada, porque ambos montam o HTML direto com HttpResponse em vez de um template — o scanner dá nota idêntica à view vulnerável e à segura. Do resto, três achados são convenções do laboratório (unvalidated-password duas vezes nos fixtures, no-csrf-exempt na view de verificação) e dois são de uma regra que vale olhar de perto. E nas definições das regras dos cinco pacotes, nenhuma contém otp, is_verified ou login_required: o ferramental não tem um conceito de segundo fator sobre o qual ter opinião.

A regra que vale olhar é is-function-without-parentheses. Ela dispara duas vezes sobre is_authenticated lido como atributo — uma na view de verificação, outra num teste:

if not request.user.is_authenticated:                       # views_verify.py:40
self.assertTrue(resp.wsgi_request.user.is_authenticated)    # tests.py:98

que é a grafia moderna correta: é propriedade desde o Django 1.10. A regra está disparando na correção, porque sinaliza qualquer atributo is_* lido sem chamada, e nada neste laboratório lê um que precise ser chamado. Aponte a mesma regra para if user.is_verified: e ela dispara, com razão: o registry já pega a versão atual da armadilha; aqui ela só não tem o que pegar.

Então este laboratório traz uma regra customizada de outro tipo: uma que precisa ser informada da política. O defeito é @login_required numa view que deveria ter levado @otp_required, e não existe diferença sintática entre isso e as milhares de views @login_required que estão inteiramente corretas. Quais views ficam atrás de um segundo fator é uma decisão de política sobre os dados que elas servem, e o Semgrep não adivinha isso. Mas consegue aplicar a decisão depois que ela é escrita. O rules/mfa.yaml do laboratório nomeia, numa regex, os models que exigem segundo fator, e sinaliza toda função com @login_required e sem @otp_required que lê um deles. Uma segunda regra, de nível INFO, lista toda chamada a login(): os caminhos que emitem sessão, do começo deste post.

semgrep scan --config labs/post_15_mfa/rules/mfa.yaml \
    labs/post_15_mfa/views_vulnerable.py labs/post_15_mfa/views_secure.py labs/_common/views.py
# Ran 2 rules on 3 files: 2 findings.

Um achado é da regra principal, em views_vulnerable.py:36; o dashboard seguro não recebe nenhum. O outro é do inventário, em labs/_common/views.py:28: o login só com senha, compartilhado, que é a porta lateral do laboratório. O arquivo de teste da regra registra o que ela não enxerga: uma leitura dentro de um helper, uma view baseada em classe, uma view decorada no urls.py, uma sessão escrita à mão. E a lista de models é mantida à mão, a mesma fraqueza da lista de URLs da varredura. As duas falham de jeitos diferentes — a regra lê código, a varredura exercita comportamento —, e é por isso que o laboratório mantém as duas.


O hábito que vale levar deste aqui é uma pergunta a fazer a qualquer aplicação que tenha MFA: de quantas maneiras dá para conseguir uma sessão aqui, e todas elas exigem o segundo fator? A resposta é um inventário, não uma opinião, e na maioria das bases de código leva uma tarde para ser produzida e é uma leitura desconfortável. O próximo da série é JWT, em que a sessão deixa de ser uma linha que o seu servidor controla e vira uma string assinada que o cliente carrega — o que muda onde essa mesma pergunta precisa ser feita.

Leitura Complementar

← Voltar para a série