Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix mass assignment in Python and Django

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Django 6.1.2 (ModelForm, django.contrib.auth sessions)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 21 KB
On this page

Steps

  1. 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__".
  2. 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.
  3. 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().
  4. For a partial update, start the form's data from the stored values of the allowed fields only, then overlay the request.
  5. Add regression tests that send the extra fields (role, tenantId and their case variants) in an ordinary update and read the stored record back.

Vulnerable, fixed and wrong

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

Vulnerableprofiles/impl/vulnerable.pyPython
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_data

With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).

The fix

Fixedprofiles/impl/fixed.pyPython
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_data

With IMPL=fixed, 6 of 6 tests pass.

A fix that looks safe but is not

Wrong fixprofiles/impl/wrong.pyPython
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_data

With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).

Route: the profile comes from request.user

profiles/views.pyPython
@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 regression test

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.

Testtests/test_profile.pyPython
# 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)

Pitfalls

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

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix mass assignment in Python and Django
ToolRuleWhat it found here
Semgreppython.django.security.injection.mass-assignment.mass-assignmentNo 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)
Semgrepp/django, p/python, p/security-audit, p/owasp-top-tenNo findings on any implementation.Checked: Semgrep 1.180.0 (CE); on profiles and config
Banditall plugins (default profile)Bandit 1.9.4No 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkDjango 6.1.2 (ModelForm, django.contrib.auth sessions)
django6.1.2
pytest9.1.1
pytest-django4.14.0

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

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

Download the project ZIP, 21 KB

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

  1. OWASP Mass Assignment Cheat Sheet
  2. OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
  3. Django 6.1: Creating forms from models, selecting the fields to use
  4. Django REST framework: ModelSerializer fields and read_only_fields
  5. CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.