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. Thefields = '__all__'view lets aPATCHover-postprice,paid, andstatus— forging a free-and-paid order and capturing a flag the legitimate flow can never reach; the explicitfields+read_only_fieldsview silently drops the same over-posts and still returns200. Reproduce both withcurl— and see the detection twist: an earlier draft of this post claimedsemgrep --config p/djangoflagsfields='__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,ModelAdminwithfields/exclude/readonly_fields). - Fields you simply do not list — an explicit
fieldsallowlist is honoured exactly. editable=Falsemodel fields (likeauto_nowtimestamps) are excluded fromModelFormbinding.
What Django does NOT protect automatically:
- Any
ModelFormorModelSerializerusingfields = '__all__'or a broadexclude. - Any view that splats
**request.data/**request.POSTinto a model constructor,create(), orupdate(). - 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
- django-security-lab — Mass Assignment lab (runnable DRF vulnerable/secure views, captured scans, and the custom Semgrep rule shared with Post 7)
- Django Docs — ModelForm: Selecting the Fields to Use
- DRF Docs — Specifying Which Fields to Include
- OWASP API Security Top 10 — Broken Object Property Level Authorization
- OWASP Cheat Sheet — Mass Assignment
- OWASP A08:2021 — Software and Data Integrity Failures
- MITRE CWE-915 — Improperly Controlled Modification of Dynamically-Determined Object Attributes
- MITRE ATT&CK — T1565.001: Stored Data Manipulation
- GitHub Blog — Public Key Security Vulnerability and Mitigation (2012)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 11: Access Control and Privilege Escalation