Give every ModelForm an explicit allowlist, fields = ["display_name", "bio"]: it binds only those fields and ignores the rest of the request. Never use fields = "__all__", exclude = [...], a setattr loop or Model.objects.create(**data) on request data. Set role and tenant_id in server-side code, never from the request.
- Find every place request data reaches a model: ModelForms with fields = "__all__" or exclude, setattr() loops, Model(**data), objects.create(**data), queryset.update(**data) and DRF serializers with fields = "__all__".
- Give each action its own form (or serializer) whose Meta.fields lists exactly what that user may change; a ModelForm then binds only those fields.
- Set server-owned fields such as role, tenant_id or owner in the view from request.user or from server-side data, after form validation and before save().
- For a partial update, start the form's data from the stored values of the allowed fields only, then overlay the request.
- Add regression tests that send the extra fields (role, tenantId and their case variants) in an ordinary update and read the stored record back.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = "__all__"
def update_profile(profile: Profile, data: dict) -> dict | None:
# PATCH: start from the stored values so fields the client leaves out stay as they are.
form = ProfileForm({**model_to_dict(profile), **data}, instance=profile)
if not form.is_valid():
return None
form.save()
return form.cleaned_dataWith IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ["display_name", "bio"] # an allowlist: role and tenant_id are set elsewhere
def update_profile(profile: Profile, data: dict) -> dict | None:
# PATCH: start from the stored values so fields the client leaves out stay as they are.
form = ProfileForm({**model_to_dict(profile, fields=ProfileForm.Meta.fields), **data}, instance=profile)
if not form.is_valid():
return None
form.save()
return form.cleaned_dataWith IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
exclude = ["role"] # set by admins only
def update_profile(profile: Profile, data: dict) -> dict | None:
# PATCH: start from the stored values so fields the client leaves out stay as they are.
form = ProfileForm({**model_to_dict(profile), **data}, instance=profile)
if not form.is_valid():
return None
form.save()
return form.cleaned_dataWith IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
Route: the profile comes from request.user
@require_http_methods(["PATCH"])
def update_me(request):
# request.user comes from the session (AuthenticationMiddleware), so PATCH /me always edits
# the signed-in user's own profile. A browser client also needs the CSRF token header.
if not request.user.is_authenticated:
return JsonResponse({"error": "sign in first"}, status=401)
try:
body = json.loads(request.body)
except ValueError:
body = None
if not isinstance(body, dict):
return JsonResponse({"error": "send a JSON object"}, status=400)
profile = request.user.profile
if load_impl().update_profile(profile, {snake_case(key): value for key, value in body.items()}) is None:
return JsonResponse({"error": "displayName and bio must be strings"}, status=400)
profile.refresh_from_db()
return JsonResponse(as_json(profile))The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: a signed-in member sends extra fields in an ordinary profile update.
def assert_server_fields_unchanged(res, users, seeded_alice) -> None:
"""The server may ignore the extra fields (200) or refuse the request (400). Either way the
stored record keeps the role and tenant the server gave it."""
assert res.status_code in (200, 400), f"expected 200 or 400, got {res.status_code}: {res.content.decode()}"
stored = users("alice")
assert stored["role"] == "member", f"role changed: {stored}"
assert stored["tenantId"] == "red", f"tenantId changed: {stored}"
assert stored == {**seeded_alice, "displayName": "Alice Martin"}, f"the stored record changed: {stored}"
def test_exploit_role_in_the_body_does_not_make_a_member_an_admin(patch_me, users, seeded_alice):
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "role": "admin"}'), users, seeded_alice)
def test_exploit_tenant_id_in_the_body_does_not_move_the_user_to_another_tenant(patch_me, users, seeded_alice):
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "tenantId": "blue"}'), users, seeded_alice)
def test_exploit_case_variants_role_and_tenant_id_do_not_change_the_record_either(patch_me, users, seeded_alice):
# The camelCase-to-snake_case step turns Role into role and TenantId into tenant_id.
assert_server_fields_unchanged(
patch_me("alice", '{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}'), users, seeded_alice)- exclude = ["role"] only removes the names you thought of. This card's wrong implementation excludes role and lets tenant_id through, and every field added to the model later is writable too. Django's documentation calls both exclude and "__all__" much less secure for this reason.
- fields = "__all__" also binds foreign keys: here it would let a client move the profile to another user by sending user.
- Renaming keys between the API and the model (camelCase to snake_case, as this card's view does) widens what reaches the form: Role and TenantId become role and tenant_id. An allowlisted form ignores them either way; a blocklist does not.
- Read-only or hidden form inputs are not protection: the client can send any field it likes. The form's Meta.fields decides what is bound, not the HTML.
- Django REST Framework: fields = "__all__" on a ModelSerializer has the same problem; list fields explicitly and mark server-owned ones in read_only_fields.
- Do not reuse an admin form or serializer that declares role for a self-service endpoint.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/python-django does this for you).
- Against your own API, sign in as an ordinary user and PATCH your profile with role, tenantId and their case variants added, then read the record back: nothing but the editable fields may change.
- grep for fields = "__all__", exclude =, setattr( and **request.; Semgrep's Django mass-assignment rule only covers objects.create(**request.X) and update(**request.X), and found nothing here.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.django.security.injection.mass-assignment.mass-assignment | No findings: the rule matches only Model.objects.create(**request.X) and obj.update(**request.X) followed by save(), not a ModelForm with fields = "__all__" or exclude.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> profiles config (Ran 1 rule) |
| Semgrep | p/django, p/python, p/security-audit, p/owasp-top-ten | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); on profiles and config |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings: Bandit has no mass-assignment check.Checked: Bandit 1.9.4; bandit -r profiles config |
In github/codeql main at b5b8165 no Python query is tagged CWE-915, so nothing in CodeQL models ModelForm field selection.
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (ModelForm, django.contrib.auth sessions) |
| django | 6.1.2 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/python-djangofolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs mass-assignment/python-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- fixed 6/6 pass; vulnerable 3/6 fail (all 3 exploit tests); wrong 2/6 fail (the tenantId and case-variant exploit tests), offline after install
- Dependency freshness
- Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10. iniconfig is pinned at 2.3.0 rather than 2.3.1 (2026-10-06), which was four days old and is not a security release. OSV reported no known vulnerabilities for any pin. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Mass Assignment Cheat Sheet
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- Django 6.1: Creating forms from models, selecting the fields to use
- Django REST framework: ModelSerializer fields and read_only_fields
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes