Mass Assignment: Como o Over-Posting Reescreve Campos Que Seu Formulário Nunca Mostrou — e Como Listas Explícitas de Campos Impedem

Mass Assignment: Como o Over-Posting Reescreve Campos Que Seu Formulário Nunca Mostrou — e Como Listas Explícitas de Campos Impedem

Django Security Series — Post 10 | Série II: Broken Access Control
OWASP A08:2021 — Software & Data Integrity Failures | Tempo de leitura: ~18 min

🧪 Rode você mesmo. Este ataque vem como um lab executável em django-security-lab: uma API de pedidos construída sobre um ModelSerializer do DRF. A view com fields = '__all__' deixa um PATCH over-postar price, paid e status — forjando um pedido gratuito e pago e capturando uma flag que o fluxo legítimo nunca alcança; a view com fields explícito + read_only_fields descarta silenciosamente os mesmos over-posts e ainda retorna 200. Reproduza ambos com curl — e veja a reviravolta na detecção: uma versão anterior deste post afirmava que semgrep --config p/django sinaliza fields='__all__' em serializers. Não sinaliza, em nenhuma camada — então o lab traz a regra customizada que de fato dispara.

(Nota de mapeamento: Mass assignment é a CWE-915, que a OWASP arquiva oficialmente sob A08 (Software & Data Integrity Failures). Este post encerra a Série II — Broken Access Control — porque o impacto é quase sempre uma falha de controle de acesso: o cliente grava um campo que nunca teve autorização para tocar. O Post 7 foi a imagem espelhada — categorizou sob A01 pelo mesmo motivo. O mesmo bug, dois lares na OWASP, dependendo de você nomeá-lo pelo mecanismo ou pela consequência.)

O Post 7 examinou uma versão específica desse bug: um formulário que expunha is_staff e permitia que um usuário se promovesse. Aquele post era dono da dimensão de permissão. Este pega o mesmo mecanismo e remove a palavra "permissão" dele por completo.

Porque eis o que eu subestimava até auditar meus próprios modelos para este post: o campo perigoso raramente é is_superuser. A maioria das aplicações Django não distribui acesso admin por um formulário de perfil. O que elas distribuem, se o formulário vincula todas as colunas, é a capacidade de definir price como zero, virar paid para true, reatribuir owner para si mesmo, ou mudar o status de um pedido de pending para shipped. Nenhum desses é uma flag de permissão. Nenhum apareceria se você só procurasse por is_staff. São campos de negócio comuns, e o mass assignment transforma cada um deles em uma entrada controlada pelo atacante.

Mass assignment (também chamado de over-posting, ou, no OWASP API Top 10, Broken Object Property Level Authorization) é o que acontece quando uma aplicação vincula um payload de requisição a um modelo sem decidir, campo por campo, quais colunas o cliente realmente pode gravar. O ModelForm do Django e o ModelSerializer do DRF ambos tornam isso o caminho de menor resistência: fields = '__all__' é mais curto de digitar do que uma lista explícita, "simplesmente funciona" em desenvolvimento, e expõe silenciosamente todas as colunas que o modelo um dia terá — incluindo as que você adiciona seis meses depois.

Enquanto pesquisava para este post, voltei ao Petição Brasil procurando o clássico buraco de is_staff do Post 7. Não encontrei um. O que encontrei foi mais interessante: um formulário de assinatura que, se tivesse usado fields = '__all__', teria permitido que um cidadão marcasse sua própria assinatura não verificada como aprovada criptograficamente — burlando todo o pipeline de validação ICP-Brasil com uma chave extra no corpo do POST. Isso não é uma escalação de privilégios no sentido de is_staff. É uma falha de integridade de dados sobre um documento juridicamente vinculante, que é exatamente o motivo pelo qual este bug vive sob A08.


O Ataque: O Que É e Como Funciona

Imagine um endpoint de edição de pedido. O formulário na tela tem um campo que o cliente pode alterar antes do checkout: quantity. Por trás desse formulário há um modelo Order com uma dúzia de colunas — quantity, price, paid, status, owner, discount_code, created_at. O desenvolvedor escreveu fields = '__all__' porque listá-las parecia redundante quando o template só renderiza um input.

O template é uma sugestão. O formulário é o contrato. Quando o cliente envia quantity=1, nada o impede de também enviar price=0&paid=true&status=shipped no mesmo corpo da requisição. Se a lista fields do formulário incluir essas colunas — e '__all__' inclui todas elas — o binder copia cada valor para a instância do modelo e o save() grava o conjunto inteiro. O cliente acabou de comprar um pedido gratuito, pré-pago e já enviado. Sem injeção, sem credencial roubada, sem kit de exploração. Ele adicionou chaves a um POST de formulário.

Esse é o ataque inteiro. A causa raiz é a mesma que atravessa toda esta série desde o Post 1: a aplicação trata a estrutura que deveria ser fixa no código como dado que vem do usuário. Na SQL injection, o dado controlado pelo usuário virou sintaxe de query. No path traversal, virou navegação de sistema de arquivos. Aqui, vira o conjunto de colunas que a gravação toca. O desenvolvedor pretendia definir esse conjunto — "o cliente pode gravar quantity" — mas ao escrever '__all__' ele delegou a decisão a quem quer que envie a requisição.

O motivo de sobreviver à revisão de código é que a linha vulnerável parece configuração, não lógica. fields = '__all__' se lê como "este formulário é sobre o objeto inteiro", o que soa razoável. Não se lê como "o cliente pode gravar todas as colunas, para sempre, incluindo colunas que ainda não existem". E é invisível em testes manuais, porque o desenvolvedor preenchendo o formulário pelo navegador só envia os campos que o template renderiza. As chaves extras são algo que você precisa pensar em enviar.

A partir daí, a exploração é mecânica. O atacante começa aprendendo o formato do modelo. Ele não precisa do seu código-fonte: uma requisição GET ao mesmo endpoint (ou uma browsable API do DRF, ou uma mensagem de erro, ou o JSON que uma view de detalhe retorna) geralmente vaza os nomes dos campos. owner, status, is_published, price, balance, verified — os campos interessantes tendem a ser adivinháveis a partir do domínio. Depois ele reenvia a gravação com as chaves extras anexadas.

Para um ModelForm do Django, o payload é um POST de formulário normal com campos adicionais que o template nunca renderizou: owner=3, status=approved, is_active=on. Para um ModelSerializer do DRF, são chaves JSON extras — {"quantity": 1, "price": "0.00", "paid": true} — que o DRF desserializa direto no validated_data, e o serializer.save() persiste. De qualquer forma, o servidor, e não o navegador, é a fronteira de segurança, e o servidor aceitou campos que deveria ter recusado.

Dois padrões de gravação são especialmente valiosos para um atacante, e ambos são campos de negócio comuns em vez de flags de permissão. O primeiro é a reatribuição de propriedade: definir uma chave estrangeira como owner, creator ou user para apontar para um recurso que o atacante não deveria controlar — o gêmeo do lado de gravação do IDOR de leitura do Post 6. O segundo é a falsificação de estado: virar um status ou booleano que o fluxo de trabalho deveria controlar — paid, approved, verification_status, is_published — para pular uma etapa da qual a lógica de negócio depende; esse é adulteração de dados, não uma referência emprestada. Nenhum dos dois toca uma flag de permissão, que é justamente o ponto deste post: o mass assignment causa seu dano muito antes de alguém recorrer a is_staff.


Incidentes Reais

GitHub Mass Assignment (2012)

Contei essa história no Post 7 pelo seu desfecho de escalação de privilégios. Ela pertence aqui por completo, porque o GitHub 2012 é o incidente arquetípico de mass assignment — e, revelador, o campo que o pesquisador over-postou não era uma flag de permissão. Em março de 2012, Egor Homakov enviou uma requisição forjada à aplicação Ruby on Rails do GitHub que over-postou o campo user_id no formulário de chave pública SSH. O Rails, como o Django, vinculava os parâmetros da requisição aos atributos do modelo sem uma lista de permissão explícita (attr_accessible não estava sendo forçado naquele modelo), então o parâmetro extra teve efeito: Homakov anexou sua própria chave pública à conta da organização Rails, uma das contas mais privilegiadas da plataforma, e demonstrou o acesso comitando um arquivo no repositório oficial do Rails.

O detalhe instrutivo para desenvolvedores Django é qual campo causou o dano. user_id é uma chave estrangeira de propriedade — o mesmo tipo de coluna que owner ou creator em um modelo Django. Ela não carregava nenhuma flag is_admin; simplesmente apontou a chave para a conta errada. Essa é a generalização que o Post 7 insinuou e que este post reforça: over-posting é perigoso sempre que qualquer campo que o cliente controla decide propriedade, estado ou dinheiro — as flags de permissão são apenas o caso mais dramático. A resposta do GitHub remodelou o ecossistema: o Rails 4 lançou os Strong Parameters, movendo a lista de permissão de campos para fora do modelo e para dentro do controller, onde o desenvolvedor é forçado a nomear as chaves permitidas por requisição. A resposta do Django é o mesmo princípio sob outro nome — a lista fields explícita em um ModelForm ou ModelSerializer — e a documentação do DRF ainda recomenda uma lista de campos explícita em vez de '__all__' exatamente pela razão que o GitHub aprendeu em público.

Em termos de ATT&CK, o over-post em si é T1190 — Exploit Public-Facing Application; o que ele alcança — reescrever silenciosamente os campos de um registro armazenado fora da autoridade da requisição — alinha-se a T1565.001 — Data Manipulation: Stored Data Manipulation, a mesma técnica quer a coluna adulterada seja o user_id do GitHub, o paid de um pedido ou o status de verificação de uma assinatura.

O peso regulatório recai onde quer que a coluna over-postada carregue significado jurídico ou pessoal. Quando o mass assignment reatribui o owner de um registro, marca uma submissão não verificada como approved, ou forja o status de um documento assinado, o resultado não é só um bug, mas uma perda de integridade sobre um registro que a lei trata como autoritativo — e se esse registro for dado pessoal, a adulteração é um evento reportável. Sob o Artigo 48 da LGPD (Lei Geral de Proteção de Dados), o controlador de uma base que sofre uma violação com risco relevante aos titulares deve comunicar a autoridade nacional (ANPD) e os titulares afetados; o GDPR europeu impõe o dever paralelo de notificação em 72 horas. Um atacante que over-posta um campo que governa a propriedade ou a integridade de dados pessoais dispara exatamente essa obrigação — o POST de uma chave e o incidente reportável são o mesmo evento visto de duas direções.

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


Proteções Padrão do Django

A resposta honesta, a mesma que o Post 7 deu, é que o Django não oferece proteção em tempo de execução contra mass assignment. Não há lista de permissão por padrão, nem aviso, nem system check. ModelForm e ModelSerializer vão vincular exatamente os campos que você mandar, e '__all__' manda vincular tudo.

O que o Django a você é um caminho seguro claramente documentado e um aviso barulhento na documentação. A documentação do ModelForm afirma sem rodeios que usar '__all__' ou exclude "pode facilmente levar a problemas de segurança quando um formulário inesperadamente permite que um usuário defina certos campos, especialmente quando novos campos são adicionados a um modelo". O guia de serializers do DRF faz a mesma recomendação: nomeie seus campos. A posição do framework é que a seleção de campos é uma decisão de segurança que o desenvolvedor deve tomar explicitamente — ele não vai adivinhar por você, e não vai impedir você de escolher '__all__'.

Há um lugar onde o Django força a lista de permissão por você, e vale a pena conhecê-lo porque é a exceção que confirma a regra: o admin. UserAdmin, ModelAdmin.fields, readonly_fields e exclude todos restringem o que o formulário do admin vincula, e o UserAdmin em particular usa um formulário curado que nunca deixa nem um superusuário virar flags de permissão casualmente pelo caminho errado. Mas o admin é uma interface exclusiva de staff com seus próprios formulários. Suas views voltadas ao cliente usam os seus formulários, e se esses vincularem '__all__', o cuidado do admin não protege nada.

Quero sinalizar uma coisa que entendi errado no começo. Assumi que o read_only_fields do DRF e um ModelSerializer marcando um campo como somente-leitura protegeriam também um serializer aninhado gravável — que se owner fosse um UserSerializer aninhado, sua condição de somente-leitura cascataria. Não cascateia. Serializers aninhados graváveis são uma superfície separada com suas próprias regras de campo, e um serializer aninhado com '__all__' reabre o buraco um nível abaixo. Volto às gravações aninhadas na seção segura, porque são a parte deste bug que tenho menos confiança de ter mapeado por completo.

O que o Django protege automaticamente:

  • Os formulários do próprio admin (UserAdmin, ModelAdmin com fields/exclude/readonly_fields).
  • Campos que você simplesmente não lista — uma lista de permissão fields explícita é honrada exatamente.
  • Campos de modelo editable=False (como timestamps auto_now) são excluídos do binding do ModelForm.

O que o Django NÃO protege automaticamente:

  • Qualquer ModelForm ou ModelSerializer usando fields = '__all__' ou um exclude amplo.
  • Qualquer view que faz splat de **request.data / **request.POST em um construtor de modelo, create() ou update().
  • Serializers aninhados graváveis, que carregam suas próprias regras de campo independentes.
  • Loops diretos de setattr(instance, key, value) sobre dados da requisição (a versão artesanal do mesmo bug).

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

Padrão 1 — Um serializer do DRF que vincula todas as colunas

A configuração que mais frequentemente entrega esse bug é o ModelViewSet de duas linhas mais o ModelSerializer com '__all__'. É o formato que os tutoriais do DRF adotam, e é conveniente precisamente porque expõe tudo.

# INSEGURO — toda coluna de Order é gravável pela API
from rest_framework import serializers, viewsets
from .models import Order

class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = '__all__'

class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer

Um cliente envia PATCH /api/orders/42/ {"price": "0.00", "paid": true, "status": "shipped"}. Toda chave é uma coluna válida, todo valor valida contra seu tipo de campo, o serializer.save() grava, e o pedido está agora gratuito e marcado como enviado. O serializer fez exatamente o que '__all__' pediu: tratou o modelo inteiro como gravável.

Padrão 2 — Reatribuição de propriedade por um ModelForm do Django

# INSEGURO — o formulário vincula a chave estrangeira owner
from django import forms
from .models import Document

class DocumentForm(forms.ModelForm):
    class Meta:
        model = Document
        fields = ['title', 'body', 'owner']   # ← owner nunca deveria ser gravável pelo cliente

O template renderiza title e body; um desenvolvedor adicionou owner à lista "para a view de criação poder defini-lo", esquecendo que uma lista é uma concessão de permissão, não uma dica de renderização. Um atacante editando seu próprio documento envia owner=<pk_da_vitima> no POST e entrega o documento a outra conta — ou, em um endpoint de criação, planta um documento como outra pessoa. Este é o lado de gravação do IDOR do Post 6: mesma verificação ausente, direção oposta. A correção não é adicionar validação ao owner; é manter owner fora do formulário por completo e defini-lo na view a partir de request.user.

Padrão 3 — O splat artesanal, onde commit=False dá falso conforto

Desenvolvedores que já se queimaram com '__all__' às vezes recorrem a save(commit=False) e acreditam que isso os torna seguros. Não torna. commit=False apenas adia a gravação no banco para que você possa definir atributos extras; não faz nada quanto aos campos que o formulário já vinculou. E o splat cru de **request.data é pior ainda, porque pula a camada de formulário por completo:

# INSEGURO — commit=False não desvincula os campos que o formulário já aceitou
def update_order(request, pk):
    order = get_object_or_404(Order, pk=pk)
    form = OrderForm(request.POST, instance=order)   # OrderForm ainda vincula '__all__'
    if form.is_valid():
        obj = form.save(commit=False)   # price/paid já copiados para obj aqui
        obj.updated_by = request.user   # esta linha está ok; não desfaz o dano
        obj.save()

# INSEGURO — o splat pula formulários e serializers por completo
def create_order(request):
    Order.objects.create(**request.POST.dict())   # toda chave postada vira uma coluna

A lição é que a vulnerabilidade vive no momento do binding, não no momento do save. commit=False está a jusante do problema. Quando você tem um obj, o price=0 do atacante já está nele, e definir updated_by depois não muda nada quanto aos campos que você nunca quis aceitar.


Implementação Segura: O Jeito Django

Regra 1 — Nomeie os campos que o cliente pode gravar, e nada mais

A correção primária é uma lista de permissão por audiência. Decida, para este endpoint e este usuário, exatamente quais colunas são graváveis, e liste-as:

# SEGURO — o cliente pode gravar quantity; nada mais é vinculado
from rest_framework import serializers
from .models import Order

class CustomerOrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ['id', 'quantity', 'status', 'price', 'paid']
        read_only_fields = ['id', 'status', 'price', 'paid']

fields controla o que o serializer conhece; read_only_fields controla o que ele vai gravar. Aqui o cliente pode ler status, price e paid (útil para exibir o pedido) mas só pode gravar quantity. O DRF remove as chaves somente-leitura do validated_data antes do save(), então um price over-postado é silenciosamente descartado em vez de rejeitado — a requisição tem sucesso, o valor adulterado não. O equivalente no ModelForm do Django é a mesma disciplina: fields = ['quantity'], e nunca adicione uma coluna a essa lista sem perguntar "o cliente deveria poder gravar isto?".

Regra 2 — Defina propriedade e estado no servidor, a partir da requisição, nunca do payload

Campos que codificam quem é o dono disto ou em que estado isto está devem ser atribuídos pelo servidor a partir de uma fonte confiável, não aceitos do cliente sob nenhuma circunstância. Mantenha-os fora de fields por completo e defina-os na view:

# SEGURO — owner vem do usuário autenticado; status é controlado pelo servidor
class DocumentForm(forms.ModelForm):
    class Meta:
        model = Document
        fields = ['title', 'body']   # owner e status NÃO estão aqui

def create_document(request):
    if request.method == 'POST':
        form = DocumentForm(request.POST)
        if form.is_valid():
            doc = form.save(commit=False)
            doc.owner = request.user           # autoritativo, da sessão
            doc.status = Document.STATUS_DRAFT  # autoritativo, do fluxo de trabalho
            doc.save()
            return redirect('document_detail', pk=doc.pk)

Note que este é o uso legítimo de commit=False: o formulário vinculou apenas os campos que deveria (title, body), e a view define os campos confiáveis depois. Contraste com o Padrão 3, onde commit=False ficava a jusante de um formulário '__all__' e não dava proteção alguma. A diferença está inteiramente no que o formulário teve permissão de vincular em primeiro lugar.

Regra 3 — Use serializers diferentes para audiências diferentes

Um único serializer tentando servir clientes, staff e chamadores internos acaba tão amplo quanto o chamador mais privilegiado precisa — que é como campos de permissão e estado vazam para gravações voltadas ao cliente. Separe-os:

# SEGURO — um serializer de cliente e um de staff, cada um escopado à sua audiência
class CustomerOrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ['id', 'quantity']

class StaffOrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ['id', 'quantity', 'status', 'paid', 'refunded']

class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()

    def get_serializer_class(self):
        if self.request.user.is_staff:
            return StaffOrderSerializer
        return CustomerOrderSerializer

O endpoint do cliente fisicamente não consegue gravar paid ou status, porque o serializer que trata a requisição do cliente não tem tais campos. A fronteira de privilégio é expressa como duas classes, não como um if em tempo de execução dentro de um serializer amplo demais — o que é mais difícil de errar e mais fácil de auditar.

Regra 4 — Prefira uma lista de permissão a uma de negação, e observe as gravações aninhadas

exclude = ['owner', 'status'] parece equivalente a uma lista fields explícita, e para o modelo de hoje é. O problema é o modelo de amanhã. Adicione uma coluna commission_rate no próximo trimestre e o formulário com exclude silenciosamente começa a aceitá-la, porque exclude é uma lista de negação e listas de negação falham abertas. fields falha fechado: uma nova coluna é invisível até alguém adicioná-la deliberadamente à lista. Essa assimetria é o argumento inteiro, e é por isso que a própria documentação do Django o direciona para fields.

Serei honesto que não considero exclude indefensável — uma equipe com um processo rigoroso de "toda mudança de modelo passa por revisão de segurança" pode usá-lo com segurança, e alguns códigos o acham mais legível. Mas ele move a segurança do código para o processo, e processos falham. O caso aninhado é onde ainda estou menos resolvido: um serializer aninhado gravável (owner = OwnerSerializer() dentro de OrderSerializer) tem seus próprios fields/read_only_fields, e marcar o owner do pai como somente-leitura não restringe o que o serializer aninhado aceita. Se você usa serializers aninhados graváveis, audite cada classe aninhada como se fosse um endpoint de gravação de nível superior, porque é o que ela é.

Checklist de Prevenção de Mass Assignment

Controle O que cobre
Lista fields explícita em todo ModelForm / ModelSerializer O vetor primário — '__all__' vincula toda coluna, incluindo as adicionadas depois
read_only_fields para colunas de estado e computadas (status, paid, signature_count) Deixa um campo ser exibido sem ser gravável; over-posts são descartados antes do save()
Propriedade/estado definidos no servidor via commit=False e então instance.owner = request.user Mantém owner/creator/status fora do alcance do cliente por completo
Serializers/formulários separados por audiência (cliente vs. staff vs. admin) Expressa a fronteira de privilégio como classes distintas, não um ramo em tempo de execução
Lista de permissão (fields) sobre lista de negação (exclude) fields falha fechado quando o modelo cresce; exclude falha aberto
Auditoria de todo serializer aninhado gravável Gravações aninhadas carregam regras de campo independentes; a condição somente-leitura do pai não cascateia
Nunca **request.data / loops de setattr em um modelo O splat artesanal pula a lista de permissão e vincula chaves arbitrárias

A Visão do Analista

Para um analista CySA+, o mass assignment é um lembrete de que a linha mais perigosa de um código pode ser a que parece configuração. fields = '__all__' não é uma chamada perigosa — não há eval, nem shell=True, nem um sink que um casador de padrões reconheça — então as ferramentas de SAST que esta série padroniza não reportam nada sobre ela. Como a seção de detecção abaixo mostra, até o ruleset Django que a reputação da classe sugere que deveria pegá-la não pega. Um relatório limpo do Bandit ou do Semgrep sobre uma camada de serializers é, portanto, evidência de nada: o controle que importa — uma lista de permissão de campos explícita e por audiência — é uma propriedade que o scanner não consegue confirmar, porque sua ausência não é um token que ele possa casar.

Isso empurra a detecção para a revisão e para o vocabulário de modelagem de ameaças que um analista já domina. Toda lista fields / read_only_fields é uma fronteira de autorização escrita em sintaxe de framework; leia-a como uma, e pergunte de cada coluna se o cliente do outro lado deste endpoint tem autorização para gravá-la. As colunas de alto valor são as que decidem propriedade, dinheiro ou estado — owner, price, paid, verification_status — o que faz disto a mesma questão de controle de acesso do resto da Série II, movida da linha que uma requisição pode ler para os campos que ela pode gravar.


Detectando Automaticamente

Testando Sua Defesa

O teste que importa aqui é aquele que o teste manual pelo navegador nunca vai executar: over-poste um campo que o template não renderiza, depois afirme que o banco de dados não mudou.

# tests/test_mass_assignment.py
from decimal import Decimal
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from .models import Order, Document


class OverPostingTests(APITestCase):
    def setUp(self):
        self.alice = User.objects.create_user('alice', password='testpass123')
        self.bob = User.objects.create_user('bob', password='testpass123')
        self.order = Order.objects.create(
            owner=self.alice, quantity=1, price=Decimal('49.90'),
            paid=False, status='pending',
        )

    def test_customer_cannot_over_post_price(self):
        """Um price over-postado deve ser ignorado, não gravado."""
        self.client.force_authenticate(user=self.alice)
        response = self.client.patch(
            f'/api/orders/{self.order.pk}/',
            {'quantity': 2, 'price': '0.00'}, format='json',
        )
        self.assertEqual(response.status_code, 200)
        self.order.refresh_from_db()
        self.assertEqual(self.order.quantity, 2)          # campo permitido gravado
        self.assertEqual(self.order.price, Decimal('49.90'))  # over-post descartado

    def test_customer_cannot_over_post_paid_or_status(self):
        """Campos de estado não devem ser graváveis pelo endpoint do cliente."""
        self.client.force_authenticate(user=self.alice)
        response = self.client.patch(
            f'/api/orders/{self.order.pk}/',
            {'paid': True, 'status': 'shipped'}, format='json',
        )
        self.assertEqual(response.status_code, 200)
        self.order.refresh_from_db()
        self.assertFalse(self.order.paid)
        self.assertEqual(self.order.status, 'pending')

    def test_owner_is_set_from_request_not_payload(self):
        """Uma requisição de criação não pode plantar um documento como outro usuário."""
        self.client.force_authenticate(user=self.alice)
        response = self.client.post(
            '/api/documents/',
            {'title': 'x', 'body': 'y', 'owner': self.bob.pk}, format='json',
        )
        self.assertEqual(response.status_code, 201)
        doc = Document.objects.get(title='x')
        self.assertEqual(doc.owner, self.alice)   # owner veio da sessão

Fazendo a Varredura

Achei que esta varredura sairia limpa no bom sentido — mass assignment é uma classe de manual, e uma versão anterior deste próprio post afirmava que semgrep --config p/django sinaliza fields='__all__' em serializers. Quando de fato apontei as ferramentas para as duas views do lab, isso se mostrou falso, e a correção é a parte útil desta seção.

O Bandit não reporta nada sobre o serializer. Seus plugins procuram chamadas perigosas (eval, subprocess … shell=True, yaml.load); uma lista de campos de serializer não é uma delas, e o Bandit não faz dataflow, então não tem como ver que '__all__' expõe price e paid. A única coisa que ele sinaliza no lab é uma senha hardcoded B106 no arquivo de teste — ruído, e um lembrete de que um relatório limpo sobre código vulnerável é um miss, não um passe.

O Semgrep community (p/django, p/python, p/owasp-top-ten) reporta zero no serializer com fields='__all__' — e, verificado diretamente antes de concluir "miss", a camada de registry (r/python.django, r/python) também. Nenhuma regra publicada do Semgrep pega esse padrão em qualquer camada que eu pudesse apontar para ele. As ferramentas que pegam são linters Django dedicados — Ruff DJ007 (django-all-with-model-form) e flake8-django DJ07 — e mesmo esses são escopados ao ModelForm, então no lado do ModelSerializer do DRF a cobertura pronta se reduz a nada. É essa lacuna que a regra customizada do lab preenche. Ela se ancora em fields = "__all__" dentro de um class Meta, dispara no serializer vulnerável e fica silenciosa na correção com lista explícita:

# Dispara na view vulnerável, silenciosa na segura — o assert que nenhuma
# camada publicada dá para esta classe.
semgrep scan --config rules/mass_assignment.yaml labs/post_10_mass_assignment/views_vulnerable.py   # 1 achado
semgrep scan --config rules/mass_assignment.yaml labs/post_10_mass_assignment/views_secure.py       # 0 achados

É a mesma regra que o Post 7 usa no seu ModelForm: um sink, dois labs, distinguidos apenas por qual campo é exposto (uma flag de permissão lá, uma coluna de negócio aqui). Seu único limite honesto é que ela mira fields = '__all__' especificamente — a variante de lista de negação exclude, que falha aberta conforme o modelo cresce, está fora de escopo e documentada como tal. Reproduzi cada execução acima e as capturei no diretório scans/ do lab.

As conclusões práticas de revisão são as que o grep e um endpoint em execução dão mais rápido do que uma varredura SAST que não reporta nada. Varra a camada de serializers e formulários diretamente:

# Todo ModelForm / ModelSerializer que vincula todas as colunas
grep -rn "fields = '__all__'" --include="*.py" apps/ | grep -v migrations
# Listas de negação exclude amplas (risco de falhar aberto)
grep -rn "exclude = " --include="*.py" apps/ | grep -v migrations
# O splat artesanal
grep -rn "objects.create(\*\*\|(\*\*request\." --include="*.py" apps/

E prove a correção dinamicamente — over-poste campos que o formulário nunca renderizou, depois releia o registro:

# Over-poste contra o endpoint seguro; os campos adulterados não podem entrar.
curl -s -X PATCH http://127.0.0.1:8000/mass-assignment/secure/orders/1/ \
  -H "Content-Type: application/json" \
  -d '{"quantity": 2, "price": "0.00", "paid": true, "status": "shipped"}'
# 200 OK — quantity=2 gravado, price/paid/status inalterados

Mass assignment é o membro quieto da Série II. IDOR, escalação de privilégios e CSRF todos soam como ataques; "o formulário vinculou um campo que você não quis expor" soa como um erro de digitação. Mas é a mesma falha de controle de acesso apontada para o caminho de gravação, e a correção é mais um hábito do que uma tecnologia: toda lista fields é uma concessão de permissão, então leia-a como uma. Se uma coluna decide propriedade, dinheiro ou estado, ela não pertence a um formulário voltado ao cliente — ela pertence à view, definida a partir de request.user ou do fluxo de trabalho.

Isso encerra a Série II. O Post 11 abre a Série III — Autenticação e Sessão — com Brute Force e Credential Stuffing, onde o ataque migra de o que você tem permissão de gravar para provar quem você é em primeiro lugar, e a decisão do Django de não embarcar nenhum rate limiting se torna a lacuna que você mesmo tem de fechar.

Leitura Complementar

← Voltar para todos os posts