skip to content

Why is fields = '__all__' on a Django ModelForm for a public pet-adoption application a mass-assignment risk?

level: seniorimportance: should knowfreq 55%

answer

  1. what the form accepts, not shows
  2. staff-only columns
  3. fields added next year
  4. allowlist over denylist
  5. server sets the rest

basics

~20 s

With fields = 'all' a Django ModelForm accepts and saves every editable field, including staff-only ones like status, and any column added later. Hiding inputs in the template does not help; a crafted POST sets them.

solid answer

~40 s

`fields = '__all__'` tells the `ModelForm` to generate, clean and save every editable model field, so the form will happily accept `status=approved` or `staff_notes` from an applicant's POST even if the template renders only four inputs; rendering controls what users see, the form's field list controls what it accepts. `exclude` is the same risk in slower motion: it is a denylist, so a column added next year appears on the public form automatically. The fix is an explicit `Meta.fields` allowlist of exactly what applicants may edit, with server-owned values such as `applicant` and `status` set in the view after `save(commit=False)` or on the `instance`, a separate form for staff, and `editable=False` for anything no form should ever write. A test that posts forbidden fields and checks they did not change keeps it honest.

code

python · 22 lines
python
from django import forms
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render

from .models import AdoptionApplication

class AdoptionApplicationForm(forms.ModelForm):
    class Meta:
        model = AdoptionApplication
        fields = ['pet', 'home_type', 'has_other_pets', 'message', 'preferred_traits']

@login_required
def apply(request):
    form = AdoptionApplicationForm(request.POST or None)
    if request.method == 'POST' and form.is_valid():
        app = form.save(commit=False)
        app.applicant = request.user                 # server-owned
        app.status = AdoptionApplication.Status.PENDING
        app.save()
        form.save_m2m()
        return redirect('applications:thanks')
    return render(request, 'applications/apply.html', {'form': form})

go deeper

for a junior

Recall that a ModelForm saves every field in its field list, so list only the fields users may edit.

for a middle

Explain how construct_instance copies cleaned data onto the instance and why unrendered fields are still accepted.

for a senior

Show the layered fix: allowlists, server-owned fields set after commit=False, separate staff forms, editable=False and tests posting forbidden fields.

for a principal

Make explicit field lists a reviewed convention and treat any 'all' or exclude in user-facing forms as a design decision needing sign-off.

## Mass assignment in Django terms **Mass assignment** is the bug where a handler copies every submitted key onto a record, letting a user set attributes the interface never offered. In Django the mechanism is concrete: a `ModelForm` generates a form field for each model field in its selection, cleans whatever the POST contains for those fields, and `save()` copies the cleaned values onto the instance through `construct_instance()`. The pet-adoption `AdoptionApplication` model has applicant-editable fields (`pet`, `home_type`, `has_other_pets`, `message`, `preferred_traits`) and server or staff fields (`applicant`, `status`, `staff_notes`). ## Why rendering does not protect you A template that shows only four inputs changes nothing about what the form **accepts**: 1. The applicant opens developer tools, or sends a request by hand. 2. The POST includes `status=approved&staff_notes=Great+home`. 3. Because `fields = '__all__'` put `status` and `staff_notes` on the form, both are cleaned; `approved` is a valid choice. 4. `form.save()` writes them. The application is approved by its own applicant. The only nuance is omission. For an optional field that has a model default and is simply absent from the POST, `construct_instance()` leaves the model default in place (checkbox and multi-select widgets excepted, because browsers omit them when unticked). That protects honest users, not attackers, who just include the field. ## Why exclude is not the fix | Declaration | What the public form accepts | When a staff field is added later | |---|---|---| | `fields = '__all__'` | every editable field | exposed immediately | | `exclude = ['status', 'staff_notes']` | everything else | exposed immediately | | `fields = ['pet', 'home_type', ...]` | exactly the list | not exposed | Django's own documentation strongly recommends listing `fields` explicitly for this reason, and warns that the problem may not even be visible on the rendered page. `'__all__'` and `exclude` are shortcuts for contexts where every field is genuinely editable by whoever reaches the form. ## A layered fix - **Allowlist**: `Meta.fields` lists only what applicants may change. - **Server-owned values** are set in code: `app = form.save(commit=False)`, then `app.applicant = request.user`, then `app.save()` and `form.save_m2m()`; or pass `instance=AdoptionApplication(applicant=request.user)` when building the form. - **A separate staff form** (`fields = ['status', 'staff_notes']`) used only in views that check staff permissions. - **`editable=False`** on the model for values no form should write, such as a computed score; every ModelForm then skips them. - **A regression test** that posts `status=approved` through the public view and asserts the stored status is still `pending`. ## Proving the form is closed A short test suite keeps the allowlist from eroding: 1. Post a valid application through the public view with `status=approved` and `staff_notes=x` added. 2. Reload the row and assert `status` is `pending` and `staff_notes` is empty. 3. Assert the form's `fields` keys equal the expected list, so adding a field to the form is a deliberate, reviewed change. 4. For the staff form, assert that a non-staff user cannot reach the view at all. ## Related traps - A field declared manually on the form but missing from `Meta.fields` is still cleaned, yet `save()` does not copy it onto the instance; keep `Meta.fields` as the single list to audit. - Model field `choices` limit values, not authority: `approved` is a perfectly valid choice, just not for this user. ## What interviewers listen for That the risk is about what the form accepts rather than what it renders, why `exclude` is a denylist that decays, and a concrete pattern for server-owned fields.

  • In Django, does leaving a field out of the template stop a ModelForm from saving it?
    No. The form accepts every field in its own field list, whatever the template renders. A POST that includes the field name is cleaned and saved like any other, so the only reliable control is the form's own field list, set by `Meta.fields`.
  • How would you give shelter staff a way to approve applications without widening the public Django form?
    Create a separate `ModelForm` with `fields = ['status', 'staff_notes']` and use it only in views that check a staff permission. Each form then states exactly which fields its audience may write, and the public form never grows.

It is like printing the shelter's own 'approved' checkbox on the applicant's copy of the paperwork and hoping nobody ticks it. Leaving the box off the printout is not enough if the office still accepts any box it finds on the returned form.

saying these in an interview costs you the question

  • Fields not rendered in the template cannot be submitted.
  • Meta.exclude is as safe as an explicit fields list.
  • Model field choices stop users from setting status to approved.
  • '__all__' is fine because new model fields rarely get added.
  • commit=False makes the form ignore fields you set later.