Mass assignment: when the framework writes the fields you never meant to expose
Mass assignment is the binding mechanism behind a whole class of privilege-escalation bugs: the framework copies every key in the request body onto your object, including the ones you never put in the form. Here is how it differs from the BOPLA authorisation question, the real CVEs, a lab, and the allowlist that ends it.
By Ahmed PingerPublished
8 min read0 views
You write an endpoint that lets a user update their display name. The form has one field. The handler is one line: take the request body, hand it to the model, save. It works, it ships, and six months later someone sends that same endpoint {"display_name": "x", "role": "admin"} and becomes an administrator. Nothing in your code granted that. The framework did, because the convenient way to write "update the user from the request" copies every key it received.
That copying is mass assignment. It is not a logic error in your handler. It is a feature of the object-binding layer doing exactly what it was built to do, against input you did not define.
The one distinction, and how it differs from BOPLA
This blog already has a post on BOLA, BFLA and BOPLA, the three object-level authorisation failures. Mass assignment lives next to BOPLA and the two are worth separating cleanly, because the fix lives in different code.
- BOPLA is an authorisation question. Is this user allowed to write this field? The post on it frames the failure at field granularity: you may update your profile, but you may not set your own
role. The decision is about permission. - Mass assignment is a binding question. How did the field get onto the object in the first place? The framework mapped an incoming JSON key straight to a model attribute with no allowlist. The decision was never made, because the code never enumerated which fields it accepted.
The relationship is that mass assignment is the most common mechanism by which a BOPLA bug becomes exploitable, and an allowlist at the binding layer is the most reliable fix. BOPLA tells you a field was written that should not have been. Mass assignment tells you why it was so easy. You can have one without the other, but in the wild they arrive together, and this post is about the binding side: the ORM call and the deserialiser, not the permission check.
Why it varies: every framework ships an autobinder
The bug is portable because the convenience is universal. Each ecosystem has a one-liner that turns a request body into a populated object, and each has a matching escape hatch that almost nobody reaches for until after the incident.
- Rails.
Model.new(params[:user])once bound everything;attr_accessible/attr_protectedwere the old opt-in, and strong parameters (params.require(:user).permit(:name)) are the current one. - Spring.
@ModelAttributebinding across all request fields, constrained with@InitBinderandsetAllowedFields(). - Laravel / Eloquent.
Model::create($request->all())against a model whose$fillablearray is missing or whose$guardedis empty. - Node.
User.create(req.body)with Mongoose or a query builder and no field picking. - ASP.NET, Django, Play. Each has model binding or a
ModelForm/serialiser that can map every field when configured to (fields = "__all__"in Django).
The variance is not whether the framework can do this safely. It is whether the developer used the safe form or the short one, and the short one is the one every tutorial shows.
The real CVEs
The modern anchor: CVE-2024-0404. NVD records a mass assignment vulnerability in the /api/invite/:code endpoint of the anything-llm project (CVSS 9.1 Critical as scored by huntr, the CNA; CWE-915, "Improperly Controlled Modification of Dynamically-Determined Object Attributes"). The description is a textbook statement of the class: by adding a role property with the value admin to the account-creation request sent through an invitation link, an attacker created a high-privileged account, "due to the lack of property allowlisting and blocklisting." The published fix commit is titled "Fixed mass_assignment vuln" and does the one thing that works: instead of passing the whole request body to user creation, it destructures username and password explicitly and sets role to a hardcoded default. The endpoint stopped trusting the body to name its own fields.
The old anchor, to show this is not new: CVE-2013-0276. NVD records that ActiveRecord in Ruby on Rails before 2.3.17, 3.1.11 and 3.2.12 "allows remote attackers to bypass the attr_protected protection mechanism and modify protected model attributes via a crafted request" (CWE-264). The reason to cite a bug from 2013 next to a current one is that the blocklist approach it broke is still the approach people reach for first, and it fails the same way every time: a blocklist protects the fields you remembered. The famous 2012 mass-assignment disclosure against GitHub, which let a researcher add his public key to the Rails organisation, was the same root cause. Rails 3.2.3 then made attribute allowlisting the default for newly generated applications. A decade later CVE-2024-0404 is the identical bug in a different language. The mechanism does not age.
A lab you can build
A Linux VM and Python are enough. No Docker.
python3 -m venv masslab && . masslab/bin/activate
pip install flask flask_sqlalchemy
Build the vulnerable version first, so you can watch a normal-looking update escalate:
# masslab.py: a teaching harness
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///mass.db"
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
display_name = db.Column(db.String)
role = db.Column(db.String, default="user")
with app.app_context():
db.create_all()
if not User.query.first():
db.session.add(User(display_name="alice", role="user"))
db.session.commit()
@app.route("/profile/<int:uid>", methods=["PATCH"])
def update(uid):
user = User.query.get_or_404(uid)
# VULNERABLE: apply every key the client sent
for key, value in request.get_json().items():
setattr(user, key, value)
db.session.commit()
return jsonify(display_name=user.display_name, role=user.role)
Run flask --app masslab run and send the update a normal client would, then the one it should never honour:
# legitimate: change the display name
curl -s -X PATCH http://127.0.0.1:5000/profile/1 \
-H 'Content-Type: application/json' -d '{"display_name":"alice-new"}'
# the attack: add a field that was never in any form
curl -s -X PATCH http://127.0.0.1:5000/profile/1 \
-H 'Content-Type: application/json' -d '{"display_name":"x","role":"admin"}'
The second response shows "role":"admin". Now replace the loop with an allowlist and rerun:
ALLOWED = {"display_name"}
body = request.get_json()
for key in ALLOWED & body.keys():
setattr(user, key, body[key])
The role key is now silently ignored because it was never on the list. That is the entire fix, and it is the same shape as the anything-llm commit: enumerate what you accept, discard the rest.
Finding it in source
You are looking for the whole-body bind, not for a bad value.
- The splat and the pass-through. Grep for
req.body,request.get_json(),params[,$request->all(),**kwargs, and spread operators handed directly into a create/update/save call. The tell is that no field names appear between the request and the model. - Blocklists instead of allowlists.
$guarded,attr_protected,setDisallowedFields, or a hand-rolled "delete these keys then save". Every one protects only the fields someone thought of, which is CVE-2013-0276's exact failure. Flag them for conversion, not for a longer blocklist. - Serialisers that read and write the same shape. A model serialised out to the client and then deserialised back in on update will happily accept
id,role,owner_idand timestamps unless the write path names its inputs separately. - The high-value fields. Search the schema for
role,is_admin,is_staff,verified,balance,price,tenant_id,owner_id. Each is a field an attacker would add to a body. Confirm every write path to them goes through an allowlist.
Fixing it
- Allowlist the bindable fields. Enumerate the fields each endpoint accepts and copy only those. This is strong parameters in Rails,
setAllowedFields()in Spring,$fillablein Eloquent, an explicit pick in Node,fieldson a Django serialiser. The OWASP Mass Assignment Cheat Sheet has the per-framework form. - Use a DTO or input schema. The most durable version is a separate type for input that physically cannot carry
roleorowner_id, so the sensitive field has no slot to land in. Bind to the DTO, then map the vetted values onto the model. - Set privileged fields server-side.
role, ownership and pricing are decided by the server from the authenticated context, never read from the body. The anything-llm fix hardcodes the default role for exactly this reason. - Prefer allowlists to blocklists, always. A blocklist is a bet that you thought of every dangerous field. You did not; that is what CVE-2013-0276 proves.
Detection
- Instrument the update path to log when a request body contains keys outside the endpoint's accepted set. On a fixed endpoint those keys are ignored; a stream of
roleoris_adminkeys arriving at a profile endpoint is someone testing the binder. - In review and CI, treat a create/update call whose argument is the raw request body as a finding on its own, before any exploit is demonstrated. The absence of named fields is the smell.
- Watch for privilege changes that did not go through your admin flow. A user whose
rolechanged on a self-service endpoint is the outcome to alert on even if you never see the request.
Take this away
Mass assignment is not a decision your code got wrong; it is a decision your code never made, because the framework mapped the request onto the object for you. BOPLA asks whether a field should have been writable. Mass assignment asks how it became writable at all, and the answer is almost always a whole-body bind with no allowlist. Name the fields you accept, set the privileged ones from the server, and the crafted extra key in the body lands nowhere. When you review a create or update handler, find the line where the request meets the model and check that a human, not the framework, chose the fields.
Sources
- NVD: CVE-2024-0404: mass assignment in anything-llm's invite endpoint, CVSS 9.1 as scored by huntr (the CNA), CWE-915
- anything-llm fix commit: destructure named fields and hardcode the role
- NVD: CVE-2013-0276: ActiveRecord
attr_protectedbypass, the blocklist failure - GitHub: Public key security vulnerability and mitigation (2012): the mass-assignment incident in which a researcher added his key to the Rails organisation
- OWASP Mass Assignment Cheat Sheet: per-framework allowlist and DTO guidance
- OWASP API3:2023 Broken Object Property Level Authorization: the authorisation framing this post sits beside
Was this useful?
Get the next write-up by email
New technical write-ups by email when we publish. No sales sequence, and every email has an unsubscribe link.
Prefer a feed? RSS. How we handle your address: privacy notice.
Comments
Loading comments…