Mass Assignment: How Over-Posting Rewrites Fields Your Form Never Showed — and How Explicit Field Lists Stop It

Mass Assignment: How Over-Posting Rewrites Fields Your Form Never Showed — and How Explicit Field Lists Stop It

Django Security Series — Post 10 | Series II: Broken Access Control
OWASP A08:2021 — Software & Data Integrity Failures | Reading time: ~18 min

🧪 Run it yourself. This attack ships as a runnable lab in django-security-lab: a customer order API built on a DRF ModelSerializer. The fields = '__all__' view lets a PATCH over-post price, paid, and status — forging a free-and-paid order and capturing a flag the legitimate flow can never reach; the explicit fields + read_only_fields view silently drops the same over-posts and still returns 200. Reproduce both with curl — and see the detection twist: an earlier draft of this post claimed semgrep --config p/django flags fields='__all__' on serializers. It does not, at any tier — so the lab ships the custom rule that actually fires.

(Mapping note: Mass assignment is CWE-915, which OWASP officially files under A08 (Software & Data Integrity Failures). This post closes Series II — Broken Access Control — because the impact is almost always an access-control failure: the client writes a field it was never authorised to touch. Post 7 was the mirror image — it categorised under A01 for the same reason. Same bug, two OWASP homes, depending on whether you name it by mechanism or by consequence.)

Post 7 looked at one specific version of this bug: a form that exposed is_staff and let a user promote themselves. That post owned the permission dimension. This one takes the same mechanism and removes the word "permission" from it entirely.

Because here is the thing I under-appreciated until I audited my own models for this post: the dangerous field is rarely is_superuser. Most Django apps don't hand out admin over a profile form. What they do hand out, if the form binds every column, is the ability to set price to zero, flip paid to true, reassign owner to yourself, or change an order's status from pending to shipped. None of those are permission flags. None of them would show up if you only grepped for is_staff. They are ordinary business fields, and mass assignment turns every one of them into an attacker-controlled input.

Mass assignment (also called over-posting, or in the OWASP API Top 10, Broken Object Property Level Authorization) is what happens when an application binds a request payload to a model without deciding, field by field, which columns the client is actually allowed to write. Django's ModelForm and DRF's ModelSerializer both make this the path of least resistance: fields = '__all__' is shorter to type than an explicit list, it "just works" in development, and it silently exposes every column the model will ever have — including the ones you add six months later.

While researching this post I went back through Petição Brasil looking for the classic is_staff hole from Post 7. I didn't find one. What I found instead was more interesting: a signature form that, had it used fields = '__all__', would have let a citizen mark their own unverified signature as cryptographically approved — bypassing the entire ICP-Brasil validation pipeline with one extra key in the POST body. That is not a privilege escalation in the is_staff sense. It is a data-integrity failure on a legally-binding document, which is exactly why this bug lives under A08.


The Attack: What It Is and How It Works

Picture an order-editing endpoint. The form on screen has one field the customer is allowed to change before checkout: quantity. Behind that form sits an Order model with a dozen columns — quantity, price, paid, status, owner, discount_code, created_at. The developer wrote fields = '__all__' because listing them felt redundant when the template only renders one input.

The template is a suggestion. The form is the contract. When the customer submits quantity=1, nothing stops them from also submitting price=0&paid=true&status=shipped in the same request body. If the form's fields list includes those columns — and '__all__' includes all of them — the binder copies each value onto the model instance and save() writes the lot. The customer just bought a free, pre-paid, already-shipped order. No injection, no stolen credential, no exploit toolkit. They added keys to a form POST.

That is the whole attack. The root cause is the one that has run through this entire series since Post 1: the application treats structure that should be fixed in code as data that comes from the user. In SQL injection the user-controlled data became query syntax. In path traversal it became filesystem navigation. Here it becomes the set of columns the write touches. The developer meant to define that set — "the customer may write quantity" — but by writing '__all__' they delegated the decision to whoever sends the request.

The reason it survives code review is that the vulnerable line looks like configuration, not logic. fields = '__all__' reads as "this form is about the whole object," which sounds reasonable. It does not read as "the client may write every column, forever, including columns that don't exist yet." And it is invisible in manual testing, because the developer filling in the form through the browser only ever sends the fields the template renders. The extra keys are something you have to think to send.

From there, exploitation is mechanical. The attacker starts by learning the model's shape. They don't need your source: a GET request to the same endpoint (or a DRF browsable API, or an error message, or the JSON a detail view returns) usually leaks the field names. owner, status, is_published, price, balance, verified — the interesting fields tend to be guessable from the domain. Then they replay the write with the extra keys attached.

For a Django ModelForm the payload is a normal form POST with additional fields the template never rendered: owner=3, status=approved, is_active=on. For a DRF ModelSerializer it is extra JSON keys — {"quantity": 1, "price": "0.00", "paid": true} — which DRF deserializes straight into validated_data and serializer.save() persists. Either way the server, not the browser, is the security boundary, and the server accepted fields it should have refused.

Two write patterns are especially valuable to an attacker, and both are ordinary business fields rather than permission flags. The first is ownership reassignment: setting a foreign key like owner, creator, or user to point at a resource the attacker shouldn't control — the write-side twin of the IDOR read from Post 6. The second is state forgery: flipping a status or boolean the workflow was supposed to own — paid, approved, verification_status, is_published — to skip a step the business logic depends on; that one is data manipulation, not a borrowed reference. Neither touches a permission flag, which is the whole point of this post: mass assignment does its damage long before anyone reaches for is_staff.


Real-World Incidents

GitHub Mass Assignment (2012)

I told this story in Post 7 for its privilege-escalation punchline. It belongs here in full, because GitHub 2012 is the archetypal mass-assignment incident — and, tellingly, the field the researcher over-posted was not a permission flag at all. In March 2012, Egor Homakov submitted a crafted request to GitHub's Ruby on Rails application that over-posted the user_id field on the SSH public-key form. Rails, like Django, bound request parameters to model attributes without an explicit allowlist (attr_accessible was not enforced on that model), so the extra parameter took effect: Homakov attached his own public key to the Rails organisation account, one of the most privileged accounts on the platform, and demonstrated the access by committing a file to the official Rails repository.

The instructive detail for Django developers is which field did the damage. user_id is an ownership foreign key — the same kind of column as owner or creator in a Django model. It carried no is_admin flag; it simply pointed the key at the wrong account. That is the generalisation Post 7 hinted at and this post insists on: over-posting is dangerous whenever any field the client controls decides ownership, state, or money — permission flags are only the most dramatic case. GitHub's response reshaped the ecosystem: Rails 4 shipped Strong Parameters, moving field allowlisting out of the model and into the controller, where the developer is forced to name the permitted keys per request. Django's answer is the same principle under a different name — the explicit fields list on a ModelForm or ModelSerializer — and DRF's documentation still steers you toward an explicit field list over '__all__' for precisely the reason GitHub learned in public.

In ATT&CK terms the over-post itself is T1190 — Exploit Public-Facing Application; what it achieves — quietly rewriting a stored record's fields outside the request's authority — aligns with T1565.001 — Data Manipulation: Stored Data Manipulation, the same technique whether the tampered column is GitHub's user_id, an order's paid, or a signature's verification status.

The regulatory weight lands wherever the over-posted column carries legal or personal significance. When mass assignment reassigns a record's owner, marks an unverified submission approved, or forges the status of a signed document, the result is not just a bug but a loss of integrity on a record the law treats as authoritative — and if that record is personal data, the tampering is a reportable event. Under Brazil's LGPD (Lei Geral de Proteção de Dados, the country's general data-protection law) Article 48, the controller of a database that suffers a breach likely to create relevant risk to data subjects must notify the national authority (ANPD) and the affected users; the EU GDPR imposes the parallel 72-hour notification duty. An attacker who over-posts a field that governs the ownership or integrity of personal data triggers exactly that obligation — the one-key POST and the reportable incident are the same event seen from two directions.

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


Django's Default Protections

The honest answer, the same one Post 7 gave, is that Django provides no runtime protection against mass assignment. There is no allowlist by default, no warning, no system check. ModelForm and ModelSerializer will bind exactly the fields you tell them to, and '__all__' tells them to bind everything.

What Django does give you is a clearly documented safe path and a loud warning in the docs. The ModelForm documentation states plainly that using '__all__' or exclude "can easily lead to security problems when a form unexpectedly allows a user to set certain fields, especially when new fields are added to a model." DRF's serializer guide makes the same recommendation: name your fields. The framework's position is that field selection is a security decision the developer must make explicitly — it will not guess for you, and it will not stop you from choosing '__all__'.

There is one place Django enforces the allowlist for you, and it is worth knowing because it is the exception that proves the rule: the admin. UserAdmin, ModelAdmin.fields, readonly_fields, and exclude all constrain what the admin form binds, and UserAdmin in particular uses a curated form that never lets even a superuser casually flip permission fields through the wrong path. But the admin is a staff-only interface with its own forms. Your customer-facing views use your forms, and if those bind '__all__', the admin's carefulness protects nothing.

I want to flag one thing I got wrong initially. I assumed that DRF's read_only_fields and a ModelSerializer marking a field as read-only would protect a writable nested serializer too — that if owner were a nested UserSerializer, its read-only-ness would cascade. It does not. Nested writable serializers are a separate surface with their own field rules, and a nested serializer with '__all__' reopens the hole one level down. I'll come back to nested writes in the secure section, because they are the part of this bug I'm least confident I have fully mapped.

What Django protects automatically:

  • The admin's own forms (UserAdmin, ModelAdmin with fields/exclude/readonly_fields).
  • Fields you simply do not list — an explicit fields allowlist is honoured exactly.
  • editable=False model fields (like auto_now timestamps) are excluded from ModelForm binding.

What Django does NOT protect automatically:

  • Any ModelForm or ModelSerializer using fields = '__all__' or a broad exclude.
  • Any view that splats **request.data / **request.POST into a model constructor, create(), or update().
  • Writable nested serializers, which carry their own independent field rules.
  • Direct setattr(instance, key, value) loops over request data (the hand-rolled version of the same bug).

Vulnerable Pattern: What NOT to Do

Pattern 1 — A DRF serializer that binds every column

The setup that ships this bug most often is the two-line ModelViewSet plus ModelSerializer with '__all__'. It is the shape DRF tutorials reach for, and it is convenient precisely because it exposes everything.

# INSECURE — every Order column is writable from the 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

A customer sends PATCH /api/orders/42/ {"price": "0.00", "paid": true, "status": "shipped"}. Every key is a valid column, every value validates against its field type, serializer.save() writes them, and the order is now free and marked shipped. The serializer did exactly what '__all__' asked: it treated the whole model as writable.

Pattern 2 — Ownership reassignment through a Django ModelForm

# INSECURE — the form binds the owner foreign key
from django import forms
from .models import Document

class DocumentForm(forms.ModelForm):
    class Meta:
        model = Document
        fields = ['title', 'body', 'owner']   # ← owner should never be client-writable

The template renders title and body; a developer added owner to the list "so the create view can set it," forgetting that a list is a permission grant, not a rendering hint. An attacker editing their own document POSTs owner=<victim_pk> and hands the document to another account — or, on a create endpoint, plants a document as someone else. This is the write-side of the IDOR from Post 6: same missing check, opposite direction. The fix is not to add validation to owner; it is to keep owner out of the form entirely and set it in the view from request.user.

Pattern 3 — The hand-rolled splat, where commit=False gives false comfort

Developers who have been burned by '__all__' sometimes reach for save(commit=False) and believe it makes them safe. It does not. commit=False only delays the database write so you can set extra attributes; it does nothing about the fields the form already bound. And the raw **request.data splat is worse still, because it skips the form layer altogether:

# INSECURE — commit=False does not un-bind the fields the form already accepted
def update_order(request, pk):
    order = get_object_or_404(Order, pk=pk)
    form = OrderForm(request.POST, instance=order)   # OrderForm still binds '__all__'
    if form.is_valid():
        obj = form.save(commit=False)   # price/paid already copied onto obj here
        obj.updated_by = request.user   # this line is fine; it does not undo the damage
        obj.save()

# INSECURE — the splat skips forms and serializers entirely
def create_order(request):
    Order.objects.create(**request.POST.dict())   # every posted key becomes a column

The lesson is that the vulnerability lives at the moment of binding, not the moment of saving. commit=False is downstream of the problem. By the time you have an obj, the attacker's price=0 is already on it, and setting updated_by afterwards changes nothing about the fields you never meant to accept.


Secure Implementation: The Django Way

Rule 1 — Name the fields the client may write, and nothing else

The primary fix is a per-audience allowlist. Decide, for this endpoint and this user, exactly which columns are writable, and list them:

# SECURE — the customer may write quantity; nothing else is bound
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 controls what the serializer knows about; read_only_fields controls what it will write. Here the customer can read status, price, and paid (useful for displaying the order) but can only write quantity. DRF strips read-only keys from validated_data before save(), so an over-posted price is silently dropped rather than rejected — the request succeeds, the tampered value does not land. The Django ModelForm equivalent is the same discipline: fields = ['quantity'], and never add a column to that list without asking "should the client be allowed to write this?"

Rule 2 — Set ownership and state server-side, from the request, never from the payload

Fields that encode who owns this or what state is this in must be assigned by the server from a trusted source, not accepted from the client under any circumstances. Keep them out of fields entirely and set them in the view:

# SECURE — owner comes from the authenticated user; status is server-controlled
class DocumentForm(forms.ModelForm):
    class Meta:
        model = Document
        fields = ['title', 'body']   # owner and status are NOT here

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          # authoritative, from the session
            doc.status = Document.STATUS_DRAFT # authoritative, from the workflow
            doc.save()
            return redirect('document_detail', pk=doc.pk)

Note that this is the legitimate use of commit=False: the form bound only the fields it should have (title, body), and the view sets the trusted fields afterwards. Contrast it with Pattern 3, where commit=False sat downstream of an '__all__' form and gave no protection at all. The difference is entirely in what the form was allowed to bind in the first place.

Rule 3 — Use different serializers for different audiences

A single serializer trying to serve customers, staff, and internal callers ends up as broad as the most privileged caller needs — which is how permission and state fields leak into customer-facing writes. Split them:

# SECURE — a customer serializer and a staff serializer, each scoped to its audience
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

The customer endpoint physically cannot write paid or status, because the serializer that handles the customer's request has no such fields. The privilege boundary is expressed as two classes, not as a runtime if inside one over-broad serializer — which is harder to get wrong and easier to audit.

Rule 4 — Prefer an allowlist to a denylist, and watch nested writes

exclude = ['owner', 'status'] looks equivalent to an explicit fields list, and for today's model it is. The problem is tomorrow's model. Add a commission_rate column next quarter and the exclude form silently starts accepting it, because exclude is a denylist and denylists fail open. fields fails closed: a new column is invisible until someone deliberately adds it to the list. That asymmetry is the whole argument, and it is why Django's own docs steer you toward fields.

I'll be honest that I don't consider exclude indefensible — a team with a strict "every model change gets a security review" process can use it safely, and some codebases find it more readable. But it moves the safety from the code to the process, and processes lapse. The nested case is where I'm still least settled: a writable nested serializer (owner = OwnerSerializer() inside OrderSerializer) has its own fields/read_only_fields, and marking the parent's owner read-only does not constrain what the nested serializer accepts. If you use writable nested serializers, audit each nested class as if it were a top-level write endpoint, because that is what it is.

Mass Assignment Prevention Checklist

Control What it covers
Explicit fields list on every ModelForm / ModelSerializer The primary vector — '__all__' binds every column, including ones added later
read_only_fields for state and computed columns (status, paid, signature_count) Lets a field be displayed without being writable; over-posts are dropped before save()
Ownership/state set server-side via commit=False then instance.owner = request.user Keeps owner/creator/status out of the client's reach entirely
Separate serializers/forms per audience (customer vs. staff vs. admin) Expresses the privilege boundary as distinct classes, not a runtime branch
Allowlist (fields) over denylist (exclude) fields fails closed when the model grows; exclude fails open
Audit of every writable nested serializer Nested writes carry independent field rules; parent read-only-ness does not cascade
Never **request.data / setattr loops into a model The hand-rolled splat skips the allowlist and binds arbitrary keys

The Analyst's View

For a CySA+ analyst, mass assignment is a reminder that the most dangerous line in a codebase can be the one that looks like configuration. fields = '__all__' is not a dangerous call — there is no eval, no shell=True, no sink a pattern-matcher recognises — so the SAST tools this series standardises on report nothing on it. As the detection section below shows, even the Django ruleset the class's reputation suggests should catch it does not. A clean Bandit or Semgrep run over a serializer layer is therefore evidence of nothing: the control that matters — an explicit, per-audience field allowlist — is a property the scanner cannot confirm, because its absence is not a token it can match.

That pushes detection onto review and the threat-modelling vocabulary an analyst already owns. Every fields / read_only_fields list is an authorization boundary written in framework syntax; read it as one, and ask of each column whether the client on the other end of this endpoint is authorized to write it. The high-value columns are the ones that decide ownership, money, or state — owner, price, paid, verification_status — which makes this the same broken-access-control question as the rest of Series II, moved from the row a request may read to the fields it may write.


Catching It Automatically

Testing Your Defence

The test that matters here is the one manual browser testing will never run: over-post a field the template does not render, then assert the database did not change.

# 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):
        """An over-posted price must be ignored, not written."""
        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)          # allowed field written
        self.assertEqual(self.order.price, Decimal('49.90'))  # over-post dropped

    def test_customer_cannot_over_post_paid_or_status(self):
        """State fields must not be writable from the customer endpoint."""
        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):
        """A create request cannot plant a document as another user."""
        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 came from the session

Scanning It

I assumed this one would scan cleanly in the good sense — mass assignment is a textbook class, and an earlier draft of this very post asserted that semgrep --config p/django flags fields='__all__' on serializers. When I actually pointed the tools at the lab's two views, that turned out to be wrong, and the correction is the useful part of this section.

Bandit reports nothing on the serializer. Its plugins look for dangerous calls (eval, subprocess … shell=True, yaml.load); a serializer's field list is not one, and Bandit does no dataflow, so it has no way to see that '__all__' exposes price and paid. The only thing it flags in the lab is a B106 hardcoded password in the test file — noise, and a reminder that a clean report on vulnerable code is a miss, not a pass.

Semgrep community (p/django, p/python, p/owasp-top-ten) reports zero on the fields='__all__' serializer — and, checked directly before concluding "miss," so does the registry tier (r/python.django, r/python). No shipped Semgrep rule catches this pattern in any tier I could point at it. The tools that do catch it are dedicated Django linters — Ruff DJ007 (django-all-with-model-form) and flake8-django DJ07 — and even those are scoped to ModelForm, so on the DRF ModelSerializer side the off-the-shelf coverage thins to nothing. That gap is why the lab ships a small custom rule. It keys on fields = "__all__" inside a class Meta, fires on the vulnerable serializer, and stays silent on the explicit-allowlist fix:

# Fires on the vulnerable view, silent on the secure one — the assert no
# shipped tier gives on this class.
semgrep scan --config rules/mass_assignment.yaml labs/post_10_mass_assignment/views_vulnerable.py   # 1 finding
semgrep scan --config rules/mass_assignment.yaml labs/post_10_mass_assignment/views_secure.py       # 0 findings

That is the same rule Post 7 uses on its ModelForm: one sink, two labs, distinguished only by which field is exposed (a permission flag there, a business column here). Its one honest limit is that it targets fields = '__all__' specifically — the exclude denylist variant, which fails open as the model grows, is out of scope and documented as such. I reproduced every run above and captured them in the lab's scans/ directory.

The practical review takeaways are the ones grep and a running endpoint give you faster than a SAST run that reports nothing. Sweep the serializer and form layer directly:

# Every ModelForm / ModelSerializer that binds all columns
grep -rn "fields = '__all__'" --include="*.py" apps/ | grep -v migrations
# Broad exclude denylists (fail-open risk)
grep -rn "exclude = " --include="*.py" apps/ | grep -v migrations
# The hand-rolled splat
grep -rn "objects.create(\*\*\|(\*\*request\." --include="*.py" apps/

And prove the fix dynamically — over-post fields the form never rendered, then read the record back:

# Over-post against the secure endpoint; the tampered fields must not land.
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 written, price/paid/status unchanged

Mass assignment is the quiet member of Series II. IDOR, privilege escalation, and CSRF all sound like attacks; "the form bound a field you didn't mean to expose" sounds like a typo. But it is the same broken-access-control failure aimed at the write path, and the fix is a habit more than a technology: every fields list is a permission grant, so read it as one. If a column decides ownership, money, or state, it does not belong in a client-facing form — it belongs in the view, set from request.user or the workflow.

That closes Series II. Post 11 opens Series III — Authentication and Session — with Brute Force and Credential Stuffing, where the attack moves from what you're allowed to write to proving who you are in the first place, and Django's decision to ship no rate limiting at all becomes the gap you have to close yourself.

Further Reading

← Back to all posts