Parameter

Mass assignment

Also known as

  • Autobinding

Mass assignment is a vulnerability where a framework copies every field in a request body onto a data model, so an attacker adds fields the form never offered, such as role, plan or owner_id, and changes properties they should not control, often granting themselves higher privileges.

Category
API security
OWASP
API3:2023 Broken Object Property Level Authorization
Last reviewed

How does mass assignment work?

Mass assignment happens when the server binds the whole request body to a model in one call, like User.update(req.body), and the model has fields the client was never meant to set. The attacker reads the model's fields from a response and sends them back with new values.

A profile form only offers a display name. The API response gives away the rest of the object:

GET /v1/users/me HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token>
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "user_a", "display_name": "demo", "role": "member", "plan": "free"}

The update request adds two fields the form never sent:

PATCH /v1/users/me HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token>
Content-Type: application/json

{"display_name": "demo", "role": "admin", "plan": "enterprise"}
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "user_a", "display_name": "demo", "role": "admin", "plan": "enterprise"}

The response echoes the new role and plan. The handler was probably one line, await user.update(req.body), and the ORM wrote every key that matched a column.

Where does mass assignment fit in the OWASP API Top 10?

In the 2023 edition it is part of API3:2023 Broken Object Property Level Authorization (BOPLA). That entry merges two 2019 categories, API3:2019 Excessive Data Exposure and API6:2019 Mass Assignment, because both are the same missing check: whether this caller may read or write this property of an object.

Direction2019 nameExample
ReadExcessive Data ExposureThe response includes internal fields the UI hides
WriteMass AssignmentThe request sets internal fields the UI never offers

The two usually appear together. The read side leaks the field names that the write side then abuses, as in the example above. OWASP's API3 scenarios include a marketplace host changing a total_stay_price property and a video platform user flipping a blocked flag.

BOPLA sits between the other two authorization failures. BOLA is access to the wrong object. BFLA is access to the wrong function. Mass assignment is write access to the wrong property of an object you are allowed to edit.

What does it look like in practice?

The best-known public case is GitHub in March 2012. A user exploited the public key update form to add his key to the Rails organization account, and GitHub's post-incident write-up names the root cause as a failure to check incoming form parameters, "a problem known as the mass-assignment vulnerability." NVD records it as CVE-2012-2055: the form accepted a public_key[user_id] value, so the key could be assigned to another user.

Common targets:

  • Privilege fields: role, is_admin, permissions, groups.
  • Ownership fields: owner_id, org_id, tenant_id. Setting these moves an object into another tenant, which turns mass assignment into a privilege escalation or a cross-tenant write.
  • Commercial state: plan, credits, trial_ends_at, discount. These are business logic impacts delivered through a binding bug.
  • Workflow state: status, approved, verified, email_verified.
  • Nested objects: {"profile": {"org": {"plan": "enterprise"}}} when the framework follows nested attributes into related models.

How do you test for mass assignment?

Collect every property the API ever returns for an object, then try to write each one back. A property that changes when it should not is a finding.

  1. Harvest field names. Read GET responses, list endpoints, the OpenAPI spec, GraphQL input types, JS bundles and error messages. Note fields the UI never lets you edit.
  2. Find every write path. Create (POST), full update (PUT), partial update (PATCH), and side routes such as signup, invite acceptance and profile import. Signup is a frequent miss because it runs before any role exists.
  3. Add one extra field at a time to a legitimate request. One at a time tells you exactly which field was accepted.
  4. Check the effect, not just the echo. Some APIs echo the request body without saving it. Re-fetch the object, or check whether the new role unlocks an admin route.
  5. Try naming variants: isAdmin, is_admin, admin, nested forms like user[role], and JSON versus form-encoded bodies.
  6. Try the read side. Compare each response to what the UI displays. Extra internal fields are the Excessive Data Exposure half of BOPLA and belong in the report too.

How do you fix mass assignment?

Allowlist the fields each endpoint accepts, per role, and bind only those. Denylists fail the first time someone adds a new column.

Django REST Framework, with explicit fields and read-only internals. The DRF docs strongly recommend listing fields explicitly over "__all__":

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "display_name", "role", "plan"]
        read_only_fields = ["id", "role", "plan"]

Express with a plain allowlist, before anything reaches the ORM:

const EDITABLE = ["display_name"];

app.patch("/v1/users/me", requireAuth, async (req, res) => {
  const changes = Object.fromEntries(
    Object.entries(req.body).filter(([key]) => EDITABLE.includes(key))
  );
  const user = await User.findByIdAndUpdate(req.user.id, changes, { new: true });
  res.json({ id: user.id, display_name: user.display_name });
});

Mongoose's strict schema option, on by default, drops keys not in the schema. It does not help when role is in the schema, which is the usual case, so it complements an allowlist and never replaces one. Set strict: "throw" in development to surface unexpected fields as errors.

Rails 8 adds params.expect, which requires and permits in one step and rejects parameters that don't match the expected shape:

# Rails 8+
def user_params
  params.expect(user: [:display_name])
end

# Older Rails
def user_params
  params.require(:user).permit(:display_name)
end

What holds across stacks:

  • Use a separate input type per endpoint and role. An admin update DTO and a member update DTO are different classes.
  • Set ownership and role from the session, never from the body.
  • Shape responses explicitly so internal fields never leak their names.

A WAF does not catch this: "role": "admin" in a JSON body is a valid request.

Written by Parameter · Last reviewed