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: ~14 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.)

O Post 6 introduziu a falha fundamental de controle de acesso em aplicações Django: uma view que verifica identidade mas nunca verifica direito — 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.

Este importa para mim porque o Petição Brasil tem um sistema de papéis em três níveis: cidadãos criam e assinam petições, moderadores aprovam/rejeitam conteúdo pelo Django admin, e administradores gerenciam toda a plataforma. A distinção entre esses níveis é aplicada por is_staff + grupo Django ("Moderadores" / "Administradores"). Se um cidadão comum pudesse fazer POST de {"is_staff": true} no seu endpoint de perfil e o formulário gravasse cegamente no banco, ele teria acesso de moderador — a capacidade de aprovar sua própria petição, dispensar denúncias contra ela, ou derrubar petições concorrentes. Precisei verificar que meus formulários de registro e perfil não expõem essa superfície.

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 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

Escalação de privilégios é o ato de obter um nível de acesso superior ao que foi concedido a você. Ela vem em duas formas:

Tipo Descrição Exemplo no Django
Vertical Um usuário com menor privilégio obtém um papel com maior privilégio Um usuário regular define is_staff = True e faz login no admin do Django
Horizontal Um usuário acessa recursos no mesmo nível de privilégio mas pertencentes a um usuário diferente Coberto pelo Post 6 (IDOR) — incluído aqui por completude

Este post é exclusivamente sobre o caso vertical. 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: submeter o campo extra junto com os legítimos, e a aplicação o grava no banco de dados porque o formulário ou serializer nunca o excluiu.

A mecânica depende de 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: quando fields = '__all__', todo campo no modelo é aceito a partir do payload da requisição. 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.

Como os Atacantes Exploram

O ataque não exige ferramentas especiais — apenas a capacidade de criar uma requisição com campos adicionais além do que o formulário do frontend apresenta:

  1. O atacante abre as DevTools do navegador e inspeciona o formulário de edição de perfil. Ele nota os campos visíveis: first_name, last_name, email.
  2. Ele submete o formulário normalmente, interceptando a requisição com um proxy (Burp Suite) ou a aba de rede do navegador. Ele adiciona um campo extra ao corpo da requisição: is_staff=true (para um POST de formulário) ou "is_staff": true (para um payload JSON de API).
  3. Se o serializer ou formulário do backend usa fields = '__all__', o campo é aceito e gravado no banco de dados.
  4. O atacante navega até /admin/ e faz login com suas próprias credenciais. Ele agora é staff.
  5. Se o site admin expõe o gerenciamento de usuários (expõe por padrão para superusers), o atacante se promove a superuser, ou diretamente submeteu "is_superuser": true no passo 2.

O ataque tem duas variantes baseadas na stack:

Baseado em formulário (Django ModelForm): O atacante adiciona campos ocultos ao formulário ou cria o corpo POST manualmente. O formulário no servidor vincula os campos extras se fields = '__all__' ou se os campos de permissão estão explicitamente incluídos.

Baseado em API (DRF ModelSerializer): O atacante adiciona chaves extras ao corpo JSON. O DRF as desserializa no dicionário de dados validados e as passa para serializer.save(), que chama instance.save() com os novos valores.

O mapeamento MITRE ATT&CK relevante é T1190 — Exploit Public-Facing Application: o atacante explora uma falha de software (a superfície de vinculação de campos desprotegida) em uma aplicação web pública para obter acesso não autorizado. Esta é uma melhor correspondência do que T1548 (Abuse Elevation Control Mechanism), que descreve o abuso de controles legítimos de elevação em nível de SO como UAC ou sudo, ou T1078 (Valid Accounts), que trata de abuso de credenciais — nenhum dos dois captura a mecânica de explorar um bug de mass assignment em código de aplicação.


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. O MITRE ATT&CK mapeia isso como T1190 (Exploit Public-Facing Application) — uma falha de software em um endpoint acessível pela web explorada para acesso não autorizado.

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__' — mas é uma nota na documentação, não uma proteção em tempo de execução. A lacuna entre ler esse alerta e o que desenvolvedores realmente constroem — ModelForm(model=User, fields='__all__') em uma página de edição de perfil — é toda a vulnerabilidade.


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__'

# 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})

O que dá errado: O formulário renderizado mostra apenas os campos que o template exibe — talvez first_name, last_name e email. Mas o ProfileForm aceita todo campo no modelo User porque fields = '__all__'. 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. A renderização de campos do template não forneceu nenhuma proteção no servidor — era apenas cosmética.

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

# 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)   # splat: aceita QUALQUER chave
    return Response({'status': 'created'}, status=201)

O que dá errado: O endpoint de registro espera username, email e password — mas **request.data passa cada chave no payload para create_user(). Um atacante submete {"username": "evil", "password": "pass123", "is_staff": true, "is_superuser": true}, e create_user() — que internamente chama User(**kwargs) — define ambas as flags na nova conta. O atacante se registra como superuser.


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

Testando Sua Defesa

Testes Unitários

# 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)

Testes de Formulário Django

# 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)

Análise Estática

# Semgrep — detectar fields='__all__' em serializers e formulários relacionados a User
semgrep --config "p/django" --include="*.py" .

# Grep — encontrar cada formulário/serializer que toca User com __all__
grep -rn "model.*=.*User" --include="*.py" | xargs grep -l "__all__"

Verificação Manual

# 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)

O Que Encontrei nos Meus Projetos

Auditei os formulários do Petição Brasil em busca exatamente da vulnerabilidade que este post descreve — um formulário que vincula is_staff, is_superuser ou groups a partir de entrada do usuário.

O formulário de registro em apps/accounts/forms.py usa uma whitelist explícita de campos:

class Meta:
    model = User
    fields = ('username', 'first_name', 'last_name', 'email', 'password1', 'password2')

Sem is_staff, sem is_superuser, sem groups. Um usuário não consegue injetar campos extras porque o ModelForm do Django ignora qualquer dado POST para campos que não estão na lista fields. Confirmei isso verificando cada classe de formulário no projeto — nenhuma usa fields = '__all__' ou exclude (que é o padrão mais fraco, de denylist).

A plataforma adiciona uma segunda camada: mesmo se um usuário de alguma forma conseguisse gravar is_staff=True no banco, ele não teria poderes de moderador. O sistema de permissões exige is_staff E pertencer ao grupo "Moderadores" ou "Administradores":

def has_moderator_permission(user):
    return user.is_staff and (
        user.groups.filter(name__in=['Moderadores', 'Administradores']).exists() or
        user.is_superuser
    )

A atribuição de grupo só pode ser feita pelo Django admin (por um superusuário). Essa dupla verificação significa que uma escalação de campo único é insuficiente — o atacante precisaria gravar tanto is_staff quanto a associação a um grupo, e não existe endpoint que exponha nenhum dos dois.

Mais uma decisão de design: o campo creator nas petições é definido server-side na view (form.instance.creator = self.request.user), nunca aceito do formulário. No Django admin, creator está bloqueado em readonly_fields — nem staff consegue reatribuir a propriedade de uma petição.


O Post 7 completa a dimensão vertical do controle de acesso quebrado. Onde o Post 6 era sobre ler objetos de outro usuário (IDOR horizontal), este post é sobre escrever seu próprio papel (escalação vertical de privilégios) — e a lição compartilhada é que o ORM e a camada de vinculação de formulários do Django não fazem distinção entre campos "seguros" e campos "perigosos". Essa distinção vive inteiramente nas definições de seus formulários, na lista explícita de fields que você escreve e mantém. A lição que tirei: trate fields = '__all__' em qualquer modelo que carrega flags de permissão como um achado crítico — a mesma severidade que uma query raw() não parametrizada na Série I.

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, porque 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