In March 2012, security researcher Egor Homakov added his own SSH key to the official Rails organization on GitHub using nothing but a public API call — no stolen credentials, no exploit chain, just a PATCH request that set a field the endpoint was never supposed to expose. The bug was mass assignment: Rails' ActiveRecord let any attribute on a model be set from a hash of request parameters unless a developer explicitly blocked it, and the GitHub endpoint in question didn't. The incident, covered widely at the time by InfoQ and The Hacker News, became the reference case OWASP now cites in its Mass Assignment Cheat Sheet whenever it needs one canonical example of the class. The bug is not Ruby-specific. In Python web apps, the identical failure mode shows up wherever a handler takes request.json or request.form and bulk-applies it to an object — via a setattr() loop, obj.__dict__.update(data), or Model(**request.json) — without checking which fields the caller is actually allowed to touch. If that object has a role, is_admin, balance, or password_hash field sitting next to the ones a user is supposed to edit, an attacker who can guess or discover the field name gets to set it directly. This post walks through where the pattern hides in Django and Flask code, and the allowlist-based patterns that close it.
What does mass assignment actually look like in Python code?
It looks like convenience code that nobody threatened-modeled. The most common form is a loop: for k, v in request.get_json().items(): setattr(user, k, v), written so a single endpoint can handle "update profile" without hand-listing every field. The second form passes the whole payload straight into a constructor or ORM call — User(**request.json) or User.objects.filter(id=id).update(**request.json) — which is even more compact and equally unguarded. The third, obj.__dict__.update(data), bypasses any property setters or validation the class defines, because it writes straight into the instance's attribute dictionary rather than going through __setattr__. In all three cases, the vulnerability isn't the language feature — setattr and **kwargs are ordinary, necessary Python — it's the absence of a check on which keys from an untrusted dict are permitted to reach the object at all. If role or is_admin is a column on the same model as email and display_name, all four travel through the same unguarded pipe.
Why does Django's ModelForm make this easy to get wrong?
Django ships a real defense — ModelForm with an explicit fields list — but its own documentation and ticket history flag a specific way developers undermine it: using Meta.exclude instead of Meta.fields. exclude is a denylist: it lists the fields to keep off the form and implicitly allows everything else, including model fields added later by someone who has no idea a form based on that model exists. Django's project documentation and the long-running ticket #8620 discussion both call this out directly, recommending fields (an allowlist) over exclude for exactly this reason — a new is_staff or credit_limit column added six months from now becomes silently editable through an old form nobody re-audited. Outside ModelForm, the same risk reappears in Django REST Framework serializers whenever fields = "__all__" is used on a model that mixes public and privileged columns, or when a view manually does Model.objects.filter(pk=pk).update(**request.data) on a queryset instead of going through a serializer's validated, allowlisted data at all.
Why is Flask more exposed to this pattern than Django?
Flask has no built-in form-binding or ORM-marshaling layer at all, which cuts both ways: there's no ModelForm-style footgun, but there's also no default guardrail. A typical Flask-SQLAlchemy handler pulls a dict straight out of request.get_json() and the developer decides, line by line, how it reaches the model — and the fastest way to write that code is the exact setattr loop or **kwargs constructor call described above. Because Flask apps commonly evolve from small internal tools into user-facing APIs, the same handler that was safe when only trusted admins hit it, and only three model fields existed, still runs unchanged after the model grows a role column and the endpoint goes public. There's no framework-level warning like Django's exclude ticket to alert anyone the pattern has become dangerous. The burden lands entirely on the developer to add a validation layer before assignment ever happens.
What are the established safe patterns?
The fix is the same across both frameworks: never let a raw client dict reach setattr, **kwargs, or __dict__.update — put a schema between them. In Django, that means declaring Meta.fields explicitly on every ModelForm and using DRF serializers with an explicit fields tuple, never "__all__", on any model with privileged columns. In Flask, that means validating the incoming payload through a schema library — Pydantic or Marshmallow — whose model defines only the client-settable fields, then assigning from the validated object rather than the raw dict; a role or is_admin field simply doesn't exist on the input schema, so there's no key for an attacker to smuggle in regardless of what the request body contains. The stronger version of this pattern separates schemas entirely: one schema for what a public API accepts on create/update, a different, server-only path for privileged fields, so the two are never one accidental **kwargs away from merging.
How does this connect to catching the bug before it ships?
Mass assignment is a dataflow bug, not a syntax bug — grepping for setattr( produces mostly false positives, since most calls to it are harmless. What actually distinguishes a dangerous call from a safe one is whether the value being assigned traces back to unvalidated request data. That's the same source-to-sink tracing problem underlying most real Python vulnerability classes: Safeguard's SAST engine, which supports Python as a phase-1 language, traces untrusted-input sources like request.json or request.form through a program's call graph to sinks such as setattr() calls or model constructors, and reports the finding with the actual dataflow trace attached rather than a bare line number. That trace is what turns "there's a setattr call in this file" into "this specific request field reaches this specific model attribute with no schema in between" — the difference between noise and an actionable, fixable finding.