Skip to main content
All articles

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 Published

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_protected were the old opt-in, and strong parameters (params.require(:user).permit(:name)) are the current one.
  • Spring. @ModelAttribute binding across all request fields, constrained with @InitBinder and setAllowedFields().
  • Laravel / Eloquent. Model::create($request->all()) against a model whose $fillable array is missing or whose $guarded is 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_id and 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, $fillable in Eloquent, an explicit pick in Node, fields on 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 role or owner_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 role or is_admin keys 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 role changed 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

Was this useful?

Share

Tags

  • api security
  • authorization
  • secure code review
  • cve analysis

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…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

API Penetration Testing

Authorisation, object-level access and abuse testing against your API surface.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.