Mass assignment vulnerabilities let attackers modify object fields that should never be user-controlled, enabling privilege escalation and data corruption via ORM auto-binding.
TL;DR
200 OK proves nothing — always diff GET before/after with a fresh sessionextra="forbid" — never pass req.body directly to a modelMass assignment (CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes) is a vulnerability where a web framework automatically maps HTTP request parameters onto the properties of a server-side object or ORM model. When the framework does not enforce an explicit allowlist of user-writable fields, an attacker can inject privileged attributes — role, is_admin, subscription_tier, credit_balance — that were never intended to be user-controlled.
The OWASP API Security Top 10 2023 subsumes mass assignment under API3:2023 BOPLA (Broken Object Property Level Authorization), which covers both the write direction (mass assignment) and the read direction (excessive data exposure). Any API endpoint that does not explicitly define which properties a caller may read or write is a candidate for BOPLA. According to OWASP, this affects 30%+ of APIs tested in 2025 in some form.
Mass assignment is architecturally distinct from IDOR (Broken Object Level Authorization / BOLA). IDOR controls which objects you can reach — whether you can access /users/2. Mass assignment controls which fields you can write on objects you legitimately access — whether updating your own profile lets you set role: "admin". The two are frequently chained: a BOLA weakness provides access to a target object, and a BOPLA weakness lets you modify its privileged fields.
The vulnerability has a single root cause: the application passes user-controlled data directly to an ORM binding or model constructor without first filtering the allowed keys. Every major framework has an auto-binding mechanism that is safe by default only if the developer explicitly configures it.
The attack flow:
PATCH /users/me, PUT /accounts/:id, POST /register.role, is_admin, subscription_tier that appear read-only.A minimal demonstration — the vulnerable pattern and its exploitation:
PATCH /api/v1/users/me HTTP/1.1
Host: target.example.com
Authorization: Bearer <user_token>
Content-Type: application/json
{
"email": "attacker@example.com",
"role": "admin",
"is_superuser": true,
"subscription_tier": "enterprise",
"credit_balance": 999999
}HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 42,
"email": "attacker@example.com",
"role": "admin",
"subscription_tier": "enterprise"
}The 200 response is necessary but not sufficient — always confirm persistence with a second GET using a different session.
| Variant | Technique | Impact |
|---|---|---|
| HTTP body injection | Extra fields in POST/PUT/PATCH JSON body | Role escalation, billing bypass |
| JSON field injection | Undocumented JSON properties, nested objects | Privilege escalation, MFA bypass |
| Rails nested attributes | role_attributes, permissions_attributes with _destroy | Nested model manipulation |
| GraphQL mutation input | Extra fields in mutation variables or InputObject | Admin takeover in headless CMS |
| ORM spread pattern | {...req.body}, User.create(params) in Express/Rails | Full model overwrite |
| JSON Patch (RFC 6902) | op: replace, path: /role on PATCH endpoint | Field-level overwrite |
| JSON Merge Patch (RFC 7396) | null value wipes ACL fields (RFC spec behavior) | Permission erasure |
| HTTP Method Override | X-HTTP-Method-Override: PATCH on POST | WAF bypass, PATCH on endpoints blocked for POST |
| Prototype pollution | __proto__: {isAdmin: true} via Node.js merge | Application-wide privilege escalation |
| NoSQL operator injection | {role: {$ne: "user"}} via Mongoose | Query manipulation + mass assignment chain |
Rails nested attributes exploit accepts_nested_attributes_for — sending role_attributes: {name: "admin"} creates or modifies associated role records if the parent model does not restrict nested attribute access.
JSON Merge Patch (RFC 7396) has a specification behavior that attackers abuse: setting a key to null in a merge-patch body deletes that key from the target object. Sending {"member_acl": null, "private": false} to a team update endpoint can wipe access control lists entirely.
GitHub 2012 — Homakov Rails Mass Assignment
In March 2012, security researcher Egor Homakov exploited a mass assignment vulnerability in GitHub's production Rails application. By posting an SSH key with an owner_id parameter pointing to the rails/rails organization, he attached his public key to the organization repository without authorization. GitHub had not restricted which POST parameters could be bound to the key model. This incident directly led to the introduction of strong_parameters as a mandatory feature in Rails 4 (2013) and remains the canonical mass assignment case study cited by OWASP, Inon Shkedy, and penetration testing curricula worldwide.
CVE-2024-52034 — Strapi CMS (CVSS 8.1, 2024)
Strapi v4.x PATCH /api/users/:id accepted the role, confirmed, and blocked fields in the request body without an explicit allowlist. Authenticated users could escalate to admin by sending {"role": "<admin-role-id>"}. The vulnerability affected the default Strapi configuration used by thousands of headless CMS deployments. Fix shipped in Strapi 4.25.x with explicit field filtering in the user-update controller. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.
CVE-2025-32433 — Directus CMS (CVSS 8.8, 2025)
Directus, an OSS data platform with 27,000+ GitHub stars, exposed the directus_users table via auto-CRUD endpoints with role writable by authenticated users. PATCH /users/me with {"role": "<admin-role-uuid>"} elevated any authenticated user to system administrator. Fix shipped in Directus 10.13+ restricts self-update endpoint to non-privileged fields. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N. Pattern: low-code/no-code platforms are the highest-prevalence mass assignment surface in 2024-2026.
CVE-2024-32887 — NocoDB (CVSS 8.8, 2024)
NocoDB (open-source Airtable alternative) table view update endpoint accepted role and visibility fields directly from the request body without a DTO layer. Authenticated users could modify table-level permissions and viewer roles. Fix in NocoDB 0.111.x introduced explicit DTO filtering for update endpoints.
CVE-2024-29133 — Apache Roller (CVSS 8.1, 2024)
Apache Roller blog platform user profile update endpoint accepted role=ADMIN via form POST. Any authenticated user could promote themselves to administrator. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N. HackerOne report HackerOne #1069904 (GitLab, $6,337 bounty) disclosed a similar pattern: PATCH /api/v4/users/:id accepted admin: true via standard user token, escalating to GitLab instance administrator.
A 200 OK response to a PATCH with injected fields does not confirm mass assignment. Rails, Django, and most modern frameworks silently ignore unpermitted fields. Confirm every finding by performing a GET with a separate session token after the PATCH and verifying the privileged field persisted. Without this cross-session confirmation step, the finding is a false positive.
PUT /users/:id, PATCH /users/me, PATCH /profile, POST /register, POST /signup, PATCH /accounts/:id. These are the highest-risk surfaces.role, is_admin, subscription_tier, credit_balance, verified, mfa_required.PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
Authorization: Bearer <user_token>
{
"email": "you@example.com",
"role": "admin",
"is_admin": true,
"is_superuser": true,
"subscription_tier": "enterprise",
"credit_balance": 999999,
"mfa_required": false,
"verified": true
}InputObject types matching the target (e.g., UserUpdateInput). Any field in the input type that is not explicitly excluded by the resolver can be written.user[role]=admin&user[is_admin]=true to URL-encoded POST bodies. Rails accepts both JSON and form-encoded with the same strong_params checks.Arjun (v3.x, Python) performs HTTP parameter discovery by sending requests with candidate parameter names from a wordlist and detecting response differences:
arjun -u "https://target.com/api/v1/users/me" -m PATCH \
--data '{"email": "x@y.com"}' \
-w /usr/share/wordlists/arjun/large.txt \
--headers "Authorization: Bearer <token>"Burp Suite Pro Param Miner detects undocumented parameters via response differential analysis. Use it against PATCH endpoints with a JSON body to discover hidden fields. The extension supports body parameter mining in addition to URL parameters.
OpenAPI-fuzzer parses your target's OpenAPI/Swagger specification, identifies fields marked readOnly: true, and automatically attempts to write them:
openapi-fuzzer -s https://target.com/openapi.json \
--bearer-token <token> \
--ignore-status-code 401x8 (Rust) provides fast parameter discovery with GraphQL and nested JSON body support — critical for discovering writable fields on GraphQL mutation endpoints.
BreachVex detects mass assignment through multiple complementary techniques: it sends a superset PATCH probe with a broad set of candidate fields and diffs the response, confirms persistence across a separate authenticated session, and validates an observable side effect (admin endpoint access or subscription feature unlock) before reporting a finding.
The primary defense is explicit declaration of which fields a user may write. Every framework has a canonical mechanism:
Rails — strong_parameters (mandatory since Rails 4):
# VULNERABLE — permits all fields
def user_params
params.require(:user).permit!
end
# SAFE — explicit allowlist, privileged fields silently stripped
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :bio)
# role, admin, subscription_tier never make it to the model
end
# ENFORCE strict mode in config/application.rb
config.action_controller.action_on_unpermitted_parameters = :raiseDjango REST Framework — explicit fields:
# VULNERABLE — exposes all model fields including is_superuser
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
# SAFE — explicit allowlist + read-only protection for privileged fields
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'email', 'first_name', 'last_name', 'bio']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'groups', 'user_permissions']Express + Mongoose — destructure or schema strict mode:
// VULNERABLE — binds entire body including role, isAdmin, stripeCustomerId
app.patch('/api/users/me', async (req, res) => {
const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
res.json(user);
});
// SAFE — explicit destructure of allowed fields
app.patch('/api/users/me', async (req, res) => {
const { email, firstName, lastName, bio } = req.body;
const user = await User.findByIdAndUpdate(
req.user.id,
{ email, firstName, lastName, bio },
{ new: true, runValidators: true }
);
res.json(user);
});DTOs provide a structural guarantee at the type level — the DTO class physically cannot contain privileged fields:
Spring Boot — DTO + @InitBinder:
// DTO — only user-writable fields exist as properties
public class UserUpdateDto {
@NotBlank @Email
private String email;
@Size(max = 100)
private String firstName;
// role, authorities, enabled are NOT present in this class
}
// Controller uses DTO, never the User entity directly
@PatchMapping("/users/me")
public ResponseEntity<UserResponse> updateMe(
@Valid @RequestBody UserUpdateDto dto,
@AuthenticationPrincipal UserDetails principal) {
return ResponseEntity.ok(userService.update(principal.getUsername(), dto));
}Pydantic v2 / FastAPI — extra="forbid":
# VULNERABLE — accepts any extra field
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="allow")
# SAFE — extra fields return 422 Unprocessable Entity
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr | None = None
first_name: str | None = None
last_name: str | None = None
# is_superuser, is_staff, role — not defined here, rejected by PydanticNestJS — ValidationPipe with whitelist:
// main.ts — global enforcement
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip undeclared properties
forbidNonWhitelisted: true // return 400 for unknown fields
}));
// DTO
export class UpdateUserDto {
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
firstName?: string;
// role, isAdmin — not in DTO, stripped by whitelist option
}Go — use struct tags for JSON binding. Go's encoding/json only decodes fields that exist in the target struct. Defining your update struct with only the allowed fields provides automatic mass assignment protection at the language level — no additional configuration required. This is one area where Go's strict typing is a security advantage over dynamic languages.
Add an automated test that verifies privileged fields cannot be written via the update endpoint:
# pytest — automated mass assignment regression test
def test_user_cannot_escalate_role(client, user_token):
response = client.patch(
"/api/v1/users/me",
json={"email": "user@example.com", "role": "admin", "is_superuser": True},
headers={"Authorization": f"Bearer {user_token}"}
)
# 200 is acceptable — but verify the role was NOT written
assert response.status_code in (200, 422)
# Confirm role not changed — use a fresh GET
me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {user_token}"})
assert me.json()["role"] == "user"
assert me.json().get("is_superuser") is not TrueMass assignment (CWE-915) occurs when a web framework automatically maps HTTP request parameters to the properties of a server-side object or ORM model without restricting which fields the user may write. An attacker injects fields like `role: 'admin'` or `is_admin: true` that were never intended to be user-controlled. The 2023 OWASP API Security Top 10 merges this with Excessive Data Exposure under API3:2023 BOPLA (Broken Object Property Level Authorization).
BOPLA (Broken Object Property Level Authorization) is the OWASP API Security Top 10 2023 name for API3. It merges two 2019 categories: API6 (Mass Assignment, write direction) and API3 (Excessive Data Exposure, read direction). If your API lets callers write fields they should not control, that is mass assignment. If it returns fields they should not read, that is excessive data exposure. Both are BOPLA.
IDOR (Insecure Direct Object Reference / BOLA) controls which objects a user can access — e.g., whether user A can read /users/2. Mass assignment (BOPLA) controls which fields a user can write on objects they legitimately access — e.g., whether a user can set role=admin on their own profile. The two are frequently chained: IDOR lets you reach the target object, BOPLA lets you modify its privileged fields.
Rails 3 and earlier auto-assigned every POST/PUT parameter to matching model attributes. The `attr_accessible` / `attr_protected` pattern was error-prone. Rails 4+ introduced strong_parameters: `params.require(:user).permit(:email, :name)`. Using `permit!` (permit all) or omitting `permit()` restores the vulnerability. Rails 8 introduced `params.expect()` with a double-bracket regression for nested hashes — see CVE tracking in brakeman.
Django REST Framework ModelSerializer with `Meta.fields = '__all__'` exposes every model field for read and write. An attacker can PATCH is_superuser, is_staff, groups, and user_permissions. The fix is an explicit fields list and `read_only_fields` for privileged attributes. Plain Django ModelForm has the same issue when Meta.fields is not restricted.
The pattern `User.findByIdAndUpdate(id, req.body)` or `Object.assign(user, req.body)` passes the full parsed JSON body to the ORM. Any field in the body — including `role`, `isAdmin`, `stripeCustomerId` — is written to the database. Fix: destructure only allowed fields from req.body, or use Mongoose schema strict mode with an explicit allowlist.
Notable CVEs: CVE-2024-52034 (Strapi CMS, CVSS 8.1), CVE-2025-32433 (Directus CMS, CVSS 8.8), CVE-2024-32887 (NocoDB, CVSS 8.8), CVE-2024-21652 (Argo CD PATCH role, CVSS 7.4), CVE-2024-29133 (Apache Roller, CVSS 8.1), CVE-2025-64459 (Django filter kwargs, CVSS 9.1), CVE-2025-23085 (Node.js Express NoSQL operator chain, CVSS 7.3), and CVE-2024-43788 (webpack prototype pollution chain, CVSS 7.3).
No. Modern frameworks silently ignore unpermitted fields and return 200. A successful attack requires confirming persistence: after the PATCH, perform a GET with a different session token and verify the privileged field changed. Confirming an observable side effect (e.g., accessing an admin endpoint) provides definitive proof.
Yes. GraphQL input types that expose `role`, `isAdmin`, or `permissions` fields in their `InputObject` accept those values through mutation variables. Headless CMS platforms (Strapi, Directus, Hasura) are the most common surfaces. Use GraphQL Voyager or introspection queries to discover unexpectedly writable fields in UserUpdateInput types.
JSON Patch (RFC 6902) is a PATCH method format using operation objects: `[{"op": "replace", "path": "/role", "value": "admin"}]`. Endpoints accepting Content-Type: application/json-patch+json that do not validate allowed paths can be exploited to overwrite any JSON field in the target object, including `role`, `permissions`, and `subscription_tier`.
Prototype pollution (CWE-1321) occurs when a mass assignment payload targets JavaScript's `__proto__` or `constructor.prototype`. Sending `{"__proto__": {"isAdmin": true}}` to a Node.js endpoint using `lodash.merge < 4.17.21` or `Object.assign` pollutes the global Object prototype, causing all subsequent object property checks for `isAdmin` to return true. This is a mass assignment to a special ORM-layer target with application-wide side effects.
Intercept a legitimate PATCH or PUT request. Send to Repeater. Add privileged fields to the JSON body: `role`, `is_admin`, `isAdmin`, `subscription_tier`, `credit_balance`. Forward and immediately GET the resource with a fresh session to check persistence. Use Param Miner extension to discover undocumented parameters. Use Arjun for automated parameter discovery across the full API surface.
Rails 8 introduced `params.expect(user: [:name, :email])` as a stricter replacement for `params.require(:user).permit()`. However, when nested hashes use the single-bracket form `params.expect(invoice: [:title, line_items_attributes: [:amount]])`, Rails incorrectly permits array-of-hashes instead of a single hash for `line_items_attributes`, allowing attribute injection on nested models. The fix is the double-bracket form: `line_items_attributes: [[:amount]]`.