Escalação de Privilégios: Como fields='__all__' Entrega a Cada Usuário as Chaves do Seu Django Admin

Escalação de Privilégios: Como fields='__all__' Entrega a Cada Usuário as Chaves do Seu Django Admin

Django Security Series — Post 7 | Série II: Broken Access Control
OWASP A01:2021 — Broken Access Control | Tempo de leitura: ~17 min

(Nota de mapeamento: O mecanismo subjacente — mass assignment — é CWE-915, que o OWASP mapeia para A08 (Software & Data Integrity Failures). Este post categoriza sob A01 porque o impacto é controle de acesso quebrado: elevação de privilégio não autorizada. Ambos os enquadramentos descrevem o mesmo bug de ângulos diferentes.)

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um formulário de edição de perfil sob @login_required com fields = '__all__', e uma versão segura com uma lista explícita de campos. Tudo roda pela linha de comando — faça login como um membro comum, POST role=staff na view vulnerável e veja uma flag exclusiva de staff ficar ao alcance; a view com allowlist descarta o campo e a flag continua trancada. Clone e reproduza cada passo.

O Post 6 introduziu a falha fundamental de controle de acesso em aplicações Django: uma view que verifica identidade mas nunca verifica direito. Esse era o caso horizontal, em que um usuário lê ou escreve objetos de outro usuário no mesmo nível de privilégio. O Post 7 leva a mesma categoria na vertical. O atacante não está alcançando lateralmente os dados de outro usuário; ele está alcançando para cima, promovendo-se de um usuário regular a staff, ou de staff a superuser. O resultado não é um vazamento de dados. É controle administrativo total sobre a aplicação.

O cenário que torna isso concreto é uma plataforma com camadas de poder — digamos um site de petições cívicas onde cidadãos criam e assinam petições, moderadores aprovam ou rejeitam conteúdo pelo Django admin, e administradores gerenciam tudo, com os níveis aplicados por is_staff mais pertencimento a grupo Django. Agora imagine que o endpoint de edição de perfil vincula tudo o que o formulário envia: um cidadão comum faz POST de {"is_staff": true} junto com a mudança do seu nome, o formulário grava, e ele tem acesso de moderador — o suficiente para aprovar sua própria petição, dispensar denúncias contra ela, ou derrubar a de um rival. Nenhuma credencial roubada, nenhuma cadeia de exploits; um campo a mais em um formulário que ele já tinha permissão de enviar. É essa a lacuna de que trata este post: a distância entre os campos que um formulário renderiza e os campos que ele aceita.

A escalação de privilégios em uma aplicação Django quase sempre chega pelo mesmo mecanismo: um formulário ou serializer que vincula dados da requisição a um modelo contendo campos que carregam permissões (is_staff, is_superuser, groups, user_permissions) sem excluí-los. O desenvolvedor construiu um endpoint de edição de perfil, usou fields = '__all__' por conveniência, e silenciosamente deu a cada usuário a capacidade de escrever seu próprio papel. O admin do Django é o prêmio final: defina is_staff = True e você pode fazer login na interface de administração, inspecionar todos os modelos e — se is_superuser também for gravável — conceder a si mesmo acesso irrestrito à aplicação inteira e seus dados.

A razão pela qual esta vulnerabilidade persiste é que o Django e o DRF (Django REST Framework — a biblioteca padrão de facto para construir APIs Web em Django) tratam campos de permissão como campos de modelo comuns. O ORM não faz distinção entre first_name e is_superuser: ambos são colunas na tabela auth_user, ambos são graváveis por save(), e ambos aparecem em fields = '__all__'. Nem ModelForm nem ModelSerializer aplica qualquer proteção especial a campos que carregam permissões. O desenvolvedor deve explicitamente excluí-los, e a falha em fazê-lo é invisível no desenvolvimento, onde o único desenvolvedor testando o endpoint já é o superuser e nunca pensaria em POSTar {"is_staff": true} contra seu próprio formulário de edição de perfil.

Neste post veremos como a escalação vertical de privilégios funciona no Django, por que ModelForm e ModelSerializer tornam fácil publicá-la, as armadilhas específicas de vinculação de campos tanto em formulários Django quanto em serializers DRF, e como fechar a lacuna com listas explícitas de campos permitidos e uma mentalidade de defesa em profundidade que trata cada endpoint gravável de User como um caminho potencial de escalação.


O Ataque: O Que É e Como Funciona

Um usuário abre as DevTools do navegador, inspeciona o formulário de edição de perfil e vê três campos: first_name, last_name, email. Ele intercepta a submissão do formulário e adiciona mais um campo ao corpo do POST: is_staff=true. O servidor aceita. Da próxima vez que ele visita /admin/, o Django o deixa entrar. Ele agora é staff.

Isso é escalação vertical de privilégios: obter um nível de acesso superior ao que foi concedido a você. O Post 6 cobriu o caso horizontal (um usuário acessando recursos de outro usuário no mesmo nível de privilégio). Este post é sobre alcançar para cima. O atacante tem uma conta legítima e autenticada. Ele descobre, por documentação, revisão de código-fonte ou simples experimentação, que um endpoint de edição de perfil ou atualização de usuário aceita campos que não deveria, e que esses campos mapeiam para as flags de permissão do Django. O ataque é uma única requisição.

A mecânica se resume a como a vinculação de formulários do Django e a vinculação de serializers do DRF funcionam. Quando um ModelForm é instanciado com request.POST, ele vincula cada chave presente nos dados POST ao campo de modelo correspondente, desde que esse campo esteja listado no atributo fields do formulário. Quando fields = '__all__', todo campo do modelo é listado. O mesmo se aplica ao ModelSerializer do DRF. O desenvolvedor pretendia que o endpoint atualizasse first_name, last_name e email, mas como nenhuma lista de permissão explícita foi definida, o endpoint também aceita is_staff, is_superuser e groups.

O ataque não exige ferramentas especiais. O atacante submete o formulário de perfil normalmente, intercepta a requisição com um proxy ou a aba de rede do navegador, e adiciona is_staff=true (POST de formulário) ou "is_staff": true (API JSON) ao corpo. Se o backend usa fields = '__all__', o campo é aceito e gravado no banco de dados. Se o site admin expõe o gerenciamento de usuários (expõe por padrão para superusers), o atacante pode ir além e submeter "is_superuser": true na mesma requisição. Funciona em ambas as stacks: com um Django ModelForm o atacante cria o corpo POST ou adiciona campos ocultos e o formulário no servidor vincula qualquer coisa em sua lista fields; com um DRF ModelSerializer ele adiciona chaves extras ao corpo JSON, o DRF as desserializa em validated_data, e serializer.save() as grava.


Incidentes Reais

Incidente de Mass Assignment no GitHub (2012)

Em março de 2012, o pesquisador de segurança Egor Homakov demonstrou uma vulnerabilidade de mass assignment contra o próprio GitHub — a plataforma que hospeda a maioria do código open-source do mundo. A aplicação Ruby on Rails do GitHub aceitava parâmetros submetidos pelo usuário sem uma lista explícita de permissão (attr_accessible não estava aplicado). Homakov criou uma requisição que fez over-posting no campo user_id no formulário de atualização de chaves públicas SSH, associando sua própria chave à conta da organização Rails — uma das contas mais privilegiadas da plataforma. Como chaves SSH são associadas a contas (não a repositórios), o ataque deu a ele acesso de commit a cada repositório pertencente àquela conta. Ele provou o ponto fazendo um commit de um arquivo no repositório oficial do Rails. O GitHub respondeu suspendendo temporariamente a conta dele (depois revertido), corrigindo a vulnerabilidade e fortalecendo suas práticas de filtragem de parâmetros. O incidente se tornou uma força motriz por trás do padrão strong-parameters introduzido no Rails 4, que substituiu a abordagem mais fraca de attr_accessible em nível de modelo por whitelisting de parâmetros em nível de controller.

O incidente foi o momento divisor de águas que fez a indústria levar a sério mass assignment e escalação de privilégios via over-posting. Suas lições se aplicam identicamente ao ModelForm do Django e ao ModelSerializer do DRF: se você não enumera quais campos o cliente pode gravar, o cliente decide por você, e os campos de permissão estão a apenas um parâmetro POST de distância de serem graváveis. O ataque não exigiu exploit, nenhuma injeção, nenhum zero-day — apenas o conhecimento de que o servidor aceitava campos que não deveria. Em termos de MITRE ATT&CK, o acesso inicial mapeia para T1190 — Exploit Public-Facing Application: o atacante explorou uma falha de software — a superfície de vinculação de campos desprotegida — para obter acesso não autorizado. (T1548, Abuse Elevation Control Mechanism, descreve elevação em nível de SO como UAC ou sudo, e T1078, Valid Accounts, trata de abuso de credenciais — nenhum captura um bug de mass assignment em código de aplicação.)

Os riscos regulatórios só aumentaram desde 2012. O incidente do GitHub é anterior às leis modernas de notificação de incidentes, mas o mesmo bug hoje tem outro peso: uma escalação por mass assignment que dá a um atacante acesso administrativo a dados pessoais é um incidente de segurança notificável — sob a LGPD do Brasil (Art. 48, comunicar a ANPD e os titulares afetados) ou o GDPR da UE (Art. 33, dever de notificação em 72 horas). Um fields = '__all__' esquecido em um modelo User é, portanto, não apenas um code smell mas uma responsabilidade de conformidade.

Source: GitHub Blog — Public Key Security Vulnerability and Mitigation (2012)


Proteções Padrão do Django

A resposta do Django à escalação de privilégios é direta: não há nenhuma. O framework não fornece proteção automática contra um formulário ou serializer que expõe campos que carregam permissões. Eis por quê:

  • ModelForm trata todos os campos igualmente. Quando você define fields = '__all__', todo campo no modelo — incluindo is_staff, is_superuser, groups e user_permissions — se torna um campo de formulário que aceita dados POST. A documentação do Django explicitamente alerta contra fields = '__all__' por exatamente esta razão, mas o alerta é uma nota na documentação, não um mecanismo de aplicação.

  • O ModelSerializer do DRF faz o mesmo. fields = '__all__' em um UserSerializer significa que o serializer aceita e grava is_staff e is_superuser a partir do payload da requisição. A documentação do DRF recomenda listas explícitas de campos, mas nada previne ou alerta em tempo de execução.

  • O modelo User não tem conceito de "campos protegidos". is_staff e is_superuser são BooleanFields no modelo User, armazenados na mesma tabela, sem nenhuma anotação em nível de Django marcando-os como sensíveis. O ORM grava qualquer valor que você definir na instância.

  • O admin do Django é a única interface embutida que aplica restrições de campos no modelo User. UserAdmin usa um UserChangeForm customizado com fieldsets cuidadosamente curados — mas o admin não é seu endpoint voltado ao usuário. Suas views customizadas e endpoints de API usam seus formulários e serializers, e se esses não excluem os campos de permissão, o Django não vai fazer isso por você.

  • @permission_required e PermissionRequiredMixin são em nível de modelo, não em nível de campo. Eles controlam acesso a uma view, não quais campos dentro da view são graváveis. Um usuário que passa has_perm('auth.change_user') pode alterar qualquer campo em qualquer usuário — incluindo promover a si mesmo — a menos que o formulário ou serializer o restrinja.

A única coisa que o Django faz corretamente é separar o admin da aplicação: o admin é uma interface de power-user com suas próprias restrições de campos. A própria página de ModelForm da documentação do Django alerta que “a falha em [definir fields explicitamente] pode facilmente levar a problemas de segurança quando um formulário permite que um usuário defina certos campos, especialmente quando novos campos são adicionados a um modelo.” Esse alerta mira diretamente em fields = '__all__'.

O que me surpreendeu durante a pesquisa é que não existe nenhuma proteção em tempo de execução. Eu esperava que o Django ao menos registrasse um warning quando fields = '__all__' inclui is_staff ou is_superuser no modelo User. Não registra. Nem mensagem de log, nem system check, nada. A lacuna entre ler o alerta da documentação e o que desenvolvedores realmente constroem — ModelForm(model=User, fields='__all__') em uma página de edição de perfil — é toda a vulnerabilidade, e o Django é completamente silencioso sobre isso.


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

Padrão 1 — Um serializer DRF com fields = '__all__' no modelo User

# INSEGURO — todo campo do modelo User é gravável pela API
from rest_framework import serializers
from django.contrib.auth.models import User

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'
# INSEGURO — a view aceita PATCH com qualquer campo
from rest_framework import generics, permissions
from django.contrib.auth.models import User
from .serializers import UserSerializer

class UserProfileView(generics.RetrieveUpdateAPIView):
    serializer_class = UserSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_object(self):
        return self.request.user

O que dá errado: Um usuário autenticado envia PATCH /api/profile/ {"is_superuser": true}. O serializer valida o campo (é um booleano, e true é um booleano válido), a view chama serializer.save(), e o usuário agora é superuser. A próxima requisição para /admin/ é bem-sucedida. O desenvolvedor assumiu que o frontend controla quais campos são submetidos — mas o atacante contorna o frontend e submete diretamente.

Padrão 2 — Um Django ModelForm com fields = '__all__'

Esta variante é enganosa porque o formulário renderizado parece seguro. O template exibe apenas first_name, last_name e email. Mas o ProfileForm aceita todo campo no modelo User porque fields = '__all__'. O que o template renderiza e o que o formulário aceita são duas coisas completamente diferentes. O atacante cria um POST com is_staff=on&is_superuser=on (convenção de checkbox do Django), o formulário valida, e form.save() grava ambas as flags no banco de dados. O template era cosmético; o formulário no servidor estava escancarado.

# INSEGURO — um formulário de edição de perfil que expõe todo campo do User
from django import forms
from django.contrib.auth.models import User

class ProfileForm(forms.ModelForm):
    class Meta:
        model = User
        fields = '__all__'
# INSEGURO — a view vincula qualquer dado POST ao modelo User
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect

@login_required
def edit_profile(request):
    if request.method == 'POST':
        form = ProfileForm(request.POST, instance=request.user)
        if form.is_valid():
            form.save()
            return redirect('profile')
    else:
        form = ProfileForm(instance=request.user)
    return render(request, 'accounts/edit_profile.html', {'form': form})

Padrão 3 — Uma view customizada de registro ou atualização que espalha dados da requisição

O endpoint de registro espera username, email e password. Mas **request.data passa cada chave no payload para create_user(), que internamente chama User(**kwargs). Um atacante submete {"username": "evil", "password": "pass123", "is_staff": true, "is_superuser": true} e se registra como superuser. Uma linha. Nenhum toolkit de exploits.

# INSEGURO — passando dados da requisição diretamente ao construtor do modelo
from django.contrib.auth.models import User
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
    User.objects.create_user(**request.data)   # ← cada chave no payload vira um campo
    return Response({'status': 'created'}, status=201)

Implementação Segura: O Jeito Django

Regra 1 — Enumere campos explicitamente — nunca use '__all__' em modelos com campos de permissão

A correção principal é a enumeração disciplinada de campos. Liste exatamente quais campos o cliente pode ler e gravar — todo o resto é excluído:

# SEGURO — apenas campos seguros de perfil são expostos; campos de permissão são excluídos
from rest_framework import serializers
from django.contrib.auth.models import User

class UserProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name', 'email']
        read_only_fields = ['id', 'username']

Mesmo se o modelo ganhar novos campos no futuro (uma fonte comum de regressões), eles não são automaticamente expostos — apenas os campos na lista explícita são vinculados. Este é o princípio da allowlist: tudo é negado a menos que explicitamente permitido.

Para o equivalente em Django ModelForm:

# SEGURO — lista explícita de campos exclui flags de permissão
from django import forms
from django.contrib.auth.models import User

class ProfileForm(forms.ModelForm):
    class Meta:
        model = User
        fields = ['first_name', 'last_name', 'email']

Regra 2 — Use read_only_fields como uma camada de defesa em profundidade para campos de permissão

Mesmo quando você tem uma lista explícita de fields que não inclui campos de permissão, adicionar read_only_fields para atributos sensíveis fornece uma segunda barreira contra refatorações futuras que possam acidentalmente reexpô-los:

# SEGURO — mesmo que alguém adicione 'is_staff' a fields depois, não pode ser gravado
class UserAdminSerializer(serializers.ModelSerializer):
    """Serializer para uso administrativo — lê campos de permissão mas nunca os grava
    a partir da requisição. Apenas código interno (management commands, signals) os define."""

    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name', 'email',
                  'is_active', 'is_staff', 'is_superuser', 'date_joined']
        read_only_fields = ['id', 'username', 'is_staff', 'is_superuser',
                            'is_active', 'date_joined']

read_only_fields garante que mesmo se o campo aparecer na saída do serializer (para exibição), ele nunca é populado a partir dos dados da requisição recebida. O DRF silenciosamente o remove de validated_data antes de chamar save().

Regra 3 — Nunca espalhe dados da requisição em construtores de modelo ou create_user()

Substitua **request.data por extração explícita dos campos esperados:

# SEGURO — apenas campos esperados são extraídos; flags de permissão não podem ser injetadas
from django.contrib.auth.models import User
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status

@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
    username = request.data.get('username')
    email = request.data.get('email')
    password = request.data.get('password')

    if not all([username, email, password]):
        return Response({'error': 'Campos obrigatórios ausentes.'}, status=status.HTTP_400_BAD_REQUEST)

    if User.objects.filter(username=username).exists():
        return Response({'error': 'Nome de usuário já existe.'}, status=status.HTTP_409_CONFLICT)

    User.objects.create_user(username=username, email=email, password=password)
    return Response({'status': 'created'}, status=status.HTTP_201_CREATED)

A extração explícita significa que nenhum campo inesperado — is_staff, is_superuser, groups — pode entrar no payload. Isso é o equivalente da parametrização SQL do Post 1: a estrutura da operação é definida no código, e apenas os valores vêm do usuário.

Regra 4 — Proteja ações administrativas com verificações explícitas de permissão

Quando uma view legitimamente precisa modificar campos de permissão — por exemplo, um endpoint administrativo que ativa ou desativa um usuário — proteja-a com uma verificação explícita de papel, nunca com o mesmo serializer usado pelo endpoint de perfil voltado ao usuário:

# SEGURO — um endpoint separado apenas para admin com aplicação explícita de permissão
from rest_framework import generics, permissions, serializers
from django.contrib.auth.models import User

class AdminUserActivationSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['is_active']

class AdminUserActivationView(generics.UpdateAPIView):
    queryset = User.objects.all()
    serializer_class = AdminUserActivationSerializer
    permission_classes = [permissions.IsAdminUser]  # apenas superusers

O princípio é serializers separados para audiências separadas: um serializer voltado ao usuário que expõe apenas campos de perfil, e um serializer voltado ao admin que expõe apenas os campos que a ação administrativa precisa, protegido por uma classe de permissão que aplica o papel de admin.

Regra 5 — Audite cada formulário e serializer que toca o modelo User

Execute uma varredura estática do seu projeto para encontrar cada ModelForm e ModelSerializer que referencia o modelo User (ou um AUTH_USER_MODEL customizado). Para cada um, verifique que:

  1. fields é uma lista explícita (nunca '__all__').
  2. Campos que carregam permissões (is_staff, is_superuser, is_active, groups, user_permissions) estão ausentes de fields ou presentes em read_only_fields.
  3. Nenhuma view espalha request.data ou request.POST em uma chamada User() ou create_user().

Uma verificação com Semgrep ou grep pode automatizar isso:

# Encontre qualquer ModelSerializer ou ModelForm em User com fields='__all__'
grep -rn "model = User" --include="*.py" | \
  xargs grep -l "fields.*=.*'__all__'" 

Qualquer resultado é um vetor potencial de escalação.

Checklist de Prevenção de Escalação de Privilégios

Controle O que cobre
Lista explícita de fields em cada ModelForm / ModelSerializer que toca User O vetor principal de escalação — '__all__' silenciosamente expõe is_staff/is_superuser
read_only_fields para is_staff, is_superuser, is_active, groups, user_permissions Defesa em profundidade — bloqueia gravações mesmo se uma refatoração futura readicionar o campo a fields
Nunca espalhar **request.data / **request.POST em um construtor de modelo O bypass de registro/atualização — atacante injeta campos arbitrários junto com os esperados
Serializers separados para endpoints voltados ao usuário vs. voltados ao admin Fronteira de privilégio — o endpoint do usuário fisicamente não pode referenciar campos de permissão
IsAdminUser ou PermissionRequiredMixin em ações apenas para admin Aplicação de gate — mesmo se o serializer fosse mal configurado, a view rejeita não-admins
Auditoria estática de cada formulário/serializer no modelo User Prevenção de regressão — captura reintrodução de '__all__' antes de publicar

Não tenho certeza completa de onde está a linha entre exclude (denylist) e fields explícito (allowlist) na prática. A documentação do Django alerta que exclude é perigoso porque novos campos adicionados ao modelo são automaticamente incluídos, e isso faz sentido. Mas já vi projetos usando exclude = ('is_staff', 'is_superuser', 'groups', 'user_permissions') e argumentando que é mais claro sobre a intenção. Optei por fields explícito porque o princípio de allowlist é mais seguro, mas consigo ver o outro argumento. A documentação do Django concorda comigo aqui, mas o fato de exclude existir e não estar deprecated provavelmente significa que o core team o considera legítimo para alguns casos de uso.


A Visão do Analista

A escalação vertical de privilégios é onde a ideia de menor privilégio do CySA+ encontra a indiferença do ORM: para o Django, is_staff é só mais uma coluna, então "menor privilégio" não é uma configuração que você liga — é uma propriedade que você tem de manter na lista de fields de cada formulário e serializer. O bug de mass assignment é a falha dessa manutenção, e vale triá-lo como alta severidade: trate fields = '__all__' em qualquer modelo que carregue flags de permissão como um achado crítico, a mesma classe de defeito que uma query raw() sem parametrização na Série I.

A ideia compensatória é defesa em profundidade — nunca deixe um único campo gravável ser toda a fronteira. Um sistema de papéis bem projetado torna a escalação um problema de dois passos: mesmo que um atacante vire is_staff, os poderes que importam (moderar conteúdo, ler dados pessoais) deveriam exigir um segundo portão que ele não consegue definir sozinho — pertencimento a grupo Django concedido apenas pelo admin, uma verificação IsAdminUser no endpoint sensível, um has_object_permission no objeto. O allowlist barra a escrita; o segundo portão garante que um allowlist esquecido não seja o fim do jogo.


Detectando Automaticamente

Testando Sua Defesa

O teste que prova a correção é uma tentativa de autopromoção que deve falhar: um usuário comum faz PATCH de {"is_staff": true} (ou POST de is_staff=on) e você afirma que a flag continua False enquanto os campos legítimos ainda salvam. Os testes de API DRF cobrem o caminho do serializer:

# tests/test_privilege_escalation.py
from rest_framework.test import APITestCase
from django.contrib.auth.models import User, Group


class PrivilegeEscalationTests(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            'regular', email='regular@example.com', password='testpass123'
        )
        self.staff_user = User.objects.create_user(
            'staffmember', email='staff@example.com', password='testpass123',
            is_staff=True,
        )
        self.target_group = Group.objects.create(name='Moderadores')

    def test_user_cannot_set_is_staff_via_profile_update(self):
        """Um usuário regular NÃO deve poder se promover a staff."""
        self.client.force_authenticate(user=self.user)
        response = self.client.patch('/api/profile/', {'is_staff': True}, format='json')
        self.assertEqual(response.status_code, 200)  # campo silenciosamente ignorado
        self.user.refresh_from_db()
        self.assertFalse(self.user.is_staff)

    def test_user_cannot_set_is_superuser_via_profile_update(self):
        """Um usuário regular NÃO deve poder se promover a superuser."""
        self.client.force_authenticate(user=self.user)
        response = self.client.patch(
            '/api/profile/', {'is_superuser': True}, format='json'
        )
        self.assertEqual(response.status_code, 200)
        self.user.refresh_from_db()
        self.assertFalse(self.user.is_superuser)

    def test_user_cannot_add_groups_via_profile_update(self):
        """Um usuário regular NÃO deve poder se adicionar a grupos."""
        self.client.force_authenticate(user=self.user)
        response = self.client.patch(
            '/api/profile/', {'groups': [self.target_group.pk]}, format='json'
        )
        self.assertEqual(response.status_code, 200)
        self.user.refresh_from_db()
        self.assertNotIn(self.target_group, self.user.groups.all())

    def test_user_can_update_safe_profile_fields(self):
        """Um usuário PODE atualizar seu próprio first_name, last_name, email."""
        self.client.force_authenticate(user=self.user)
        response = self.client.patch(
            '/api/profile/',
            {'first_name': 'Atualizado', 'last_name': 'Nome', 'email': 'novo@example.com'},
            format='json',
        )
        self.assertEqual(response.status_code, 200)
        self.user.refresh_from_db()
        self.assertEqual(self.user.first_name, 'Atualizado')
        self.assertEqual(self.user.last_name, 'Nome')
        self.assertEqual(self.user.email, 'novo@example.com')

    def test_registration_cannot_set_is_staff(self):
        """O registro NÃO deve aceitar flags de permissão no payload."""
        response = self.client.post('/api/register/', {
            'username': 'attacker',
            'email': 'attacker@example.com',
            'password': 'strongpass123',
            'is_staff': True,
            'is_superuser': True,
        }, format='json')
        self.assertEqual(response.status_code, 201)
        new_user = User.objects.filter(username='attacker').first()
        self.assertIsNotNone(new_user)  # usuário deve ser criado
        self.assertFalse(new_user.is_staff)
        self.assertFalse(new_user.is_superuser)

    def test_staff_cannot_promote_self_to_superuser(self):
        """Mesmo usuários staff NÃO devem poder se autopromover a superuser via API."""
        self.client.force_authenticate(user=self.staff_user)
        response = self.client.patch(
            '/api/profile/', {'is_superuser': True}, format='json'
        )
        self.assertEqual(response.status_code, 200)
        self.staff_user.refresh_from_db()
        self.assertFalse(self.staff_user.is_superuser)

O caminho do ModelForm do Django merece seu próprio teste — um POST forjado com is_staff=on não deve promover o usuário:

# tests/test_privilege_escalation_form.py
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User


class ProfileFormEscalationTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            'alice', email='alice@example.com', password='testpass123'
        )

    def test_form_post_cannot_set_is_staff(self):
        """Um POST criado com is_staff=on não deve promover o usuário."""
        self.client.login(username='alice', password='testpass123')
        response = self.client.post('/accounts/profile/edit/', {
            'first_name': 'Alice',
            'last_name': 'Silva',
            'email': 'alice@example.com',
            'is_staff': 'on',          # injetado
            'is_superuser': 'on',      # injetado
        })
        self.user.refresh_from_db()
        self.assertFalse(self.user.is_staff)
        self.assertFalse(self.user.is_superuser)

Você também pode provar à mão contra uma instância em execução — o token de um usuário comum fazendo PATCH nas flags de permissão, e então confirmando que elas não pegaram:

# Tentar escalação de privilégio contra o endpoint de perfil
curl -X PATCH https://staging.example.com/api/profile/ \
  -H "Authorization: Token <token-usuario-regular>" \
  -H "Content-Type: application/json" \
  -d '{"is_staff": true, "is_superuser": true}'

# Verificar que o usuário NÃO foi promovido
curl https://staging.example.com/api/profile/ \
  -H "Authorization: Token <token-usuario-regular>" | python -m json.tool
# Esperado: "is_staff": false, "is_superuser": false (ou campos ausentes da resposta)

Fazendo a Varredura

Eis o que a ferramentaria ensina: os scanners padrão que esta série roda não pegam fields = '__all__'. Aponte o Bandit para o laboratório e ele não reporta nada nas views e formulários — suas verificações são para chamadas perigosas (eval, subprocess … shell=True, mark_safe), e a lista de fields de um formulário não é uma delas; a única coisa que ele sinaliza é uma senha embutida no arquivo de teste, ruído sem relação com a classe. Os pacotes da comunidade do Semgrep (p/django, p/python, p/owasp-top-ten) também não reportam nada e — verificado diretamente — nem o ruleset de registro r/python.django (só dispara um nit de estilo não relacionado, "use render() em vez de HttpResponse").

Vale parar nisso, porque fields = '__all__' é um antipadrão bem conhecido — ele só não é uma regra do Semgrep. As ferramentas que o sinalizam são linters Django dedicados: o DJ007 (django-all-with-model-form) do Ruff e o DJ07 do flake8-django. Rode-os e você o pega — mas um analista padronizado em Bandit + Semgrep recebe um relatório limpo sobre código genuinamente vulnerável, o que é um miss, não um passe.

Por isso o laboratório deste post traz uma pequena regra Semgrep personalizada, rules/mass_assignment.yaml, que sinaliza fields = "__all__" dentro de um Meta (cobrindo tanto ModelForm quanto ModelSerializer). Ela dispara na view vulnerável e fica em silêncio na correção com allowlist — o teste dispara/silencia que as ferramentas padrão não conseguiram dar — imposto em um job de CI hermético. A mesma regra serve ao Post 10 (Mass Assignment): o mesmo sink, um campo exposto diferente.

# as ferramentas padrão — perdem a classe
bandit -r labs/post_07_privesc/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_07_privesc/

# a regra personalizada — pega
semgrep --test --config rules/mass_assignment.yaml rules/mass_assignment.py
semgrep scan --config rules/mass_assignment.yaml labs/post_07_privesc/views_vulnerable.py  # 1 achado
semgrep scan --config rules/mass_assignment.yaml labs/post_07_privesc/views_secure.py      # 0 achados

Não há DAST de apertar-o-botão para este: um scanner pode enviar is_staff=true, mas não tem como saber que aquele campo nunca deveria ser gravável pelo cliente ou que virá-lo cruza uma fronteira de privilégio — isso é o seu allowlist e modelo de papéis, não algo que uma ferramenta black-box infere. As execuções capturadas de Bandit, Semgrep e da regra personalizada estão versionadas em scans/; o tests.py é a prova executável.


O ORM do Django não faz distinção entre first_name e is_superuser — ambos são apenas colunas em auth_user. Essa distinção vive inteiramente na lista fields que você escreve e mantém, e é por isso que a escalação vertical é uma disciplina de manutenção, não um recurso do framework que você liga.

O Post 8 continua a Série II com Cross-Site Request Forgery (CSRF), em que o atacante não precisa das credenciais da vítima. O próprio navegador da vítima submete a requisição em nome do atacante, aproveitando o cookie de sessão que o sistema de autenticação do Django tão cuidadosamente configurou.

Leitura Complementar

← Voltar para todos os posts