Mass Assignment Vulnerabilities: Testing for API Parameter Tampering
Mass assignment is one of those vulnerabilities that seems minor when you first encounter it and turns out to be catastrophic when you probe it on a real application. The GitHub incident of 2012 — where a researcher bypassed SSH key restrictions by adding a parameter to a git push request — is the canonical example. The attacker gained write access to any repository on GitHub by setting an internal attribute that the API exposed but didn't restrict.
More than a decade later, mass assignment remains a consistent finding in API security assessments. The root cause hasn't changed: frameworks that automatically bind request body parameters to object properties make it convenient to build APIs, and that same convenience creates the vulnerability when internal properties aren't explicitly protected.
What Mass Assignment Is
Mass assignment occurs when a web API or application automatically binds HTTP request parameters (query string, body JSON, form fields) to the properties of a server-side object — without filtering which properties are user-controllable.
A typical registration endpoint might look like this on the surface:
POST /api/users/register
{
"username": "alice",
"email": "alice@example.com",
"password": "correcthorsebatterystaple"
}
But the underlying User model might have additional fields: role, isAdmin, creditBalance, emailVerified, subscriptionTier. If the API passes the request body directly to the model constructor without filtering, an attacker can include those fields too:
POST /api/users/register
{
"username": "alice",
"email": "alice@example.com",
"password": "correcthorsebatterystaple",
"isAdmin": true,
"role": "admin",
"subscriptionTier": "enterprise"
}
If the backend accepts these without rejecting the extra fields, Alice just created an admin account with an enterprise subscription.
Framework-Specific Implementations
Ruby on Rails
Rails introduced Strong Parameters in Rails 4 specifically to address mass assignment after a series of high-profile vulnerabilities. The exploit pattern with Active Record was: Model.new(params) — which assigned all request params to model attributes directly.
Modern Rails requires explicit parameter permitting:
# Vulnerable (Rails 3 style, or bypassed strong params):
@user = User.new(params[:user])
# Correct (Rails 4+ with Strong Parameters):
def user_params
params.require(:user).permit(:username, :email, :password)
# :role, :isAdmin not permitted — will be filtered out
end
@user = User.new(user_params)
When testing Rails applications, look for .permit! calls (which whitelist all parameters) and patterns where params are passed without going through a permit block.
Node.js / Express
Node.js doesn't have framework-level protection. Vulnerable patterns are explicit in the code:
// Vulnerable — spreads entire request body onto user update
app.put('/api/users/:id', async (req, res) => {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body, // ← entire body, including any field
{ new: true }
)
res.json(user)
})
// Correct — explicit field selection
app.put('/api/users/:id', async (req, res) => {
const allowedFields = ['username', 'email', 'bio']
const updates = Object.keys(req.body)
.filter(key => allowedFields.includes(key))
.reduce((obj, key) => ({ ...obj, [key]: req.body[key] }), {})
const user = await User.findByIdAndUpdate(req.params.id, updates, { new: true })
res.json(user)
})
Django / DRF
Django REST Framework serializers control which fields are accepted. The vulnerability appears when serializers use fields = '__all__' or when internal fields aren't marked read_only:
# Vulnerable — all fields writable including is_staff, is_admin
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
# Correct — explicit field list with sensitive fields as read-only
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'bio', 'is_staff', 'date_joined']
read_only_fields = ['id', 'is_staff', 'date_joined']
Laravel / PHP
Laravel's Eloquent uses a $fillable array (whitelist) or $guarded array (blacklist) to control mass assignment. Using $guarded = [] (empty guard) is equivalent to disabling protection entirely:
// Vulnerable — no fillable restriction
class User extends Model {
protected $guarded = []; // ← nothing is protected
}
// Correct
class User extends Model {
protected $fillable = ['username', 'email', 'password'];
// or:
protected $guarded = ['is_admin', 'role', 'balance'];
}
Testing for Mass Assignment
Step 1: Identify writable endpoints
Mass assignment is possible on any endpoint that accepts user input and writes it to a data store. Focus on:
- User registration and profile update endpoints
- REST PUT/PATCH operations on any resource
- Form submission endpoints
- Bulk update operations
- Any endpoint that reads a JSON body and creates or updates a record
Step 2: Discover hidden properties
The challenge with mass assignment testing is knowing which undocumented properties exist. Several techniques help:
GET vs POST property comparison: Perform a GET request on the same resource. The response likely includes all model fields — including those that aren't in the POST documentation.
# Profile update accepts:
POST /api/users/profile
{"bio": "Security researcher"}
# Profile GET returns:
GET /api/users/profile
{
"id": 42,
"username": "alice",
"bio": "Security researcher",
"role": "user", ← internal field
"isAdmin": false, ← internal field
"subscriptionTier": "free", ← internal field
"emailVerified": true, ← internal field
"createdAt": "2026-01-01T00:00:00Z"
}
Fields present in the GET response but not documented in the POST spec are candidates for mass assignment testing. Try including each in the POST body and observe the response.
JavaScript source analysis: Frontend JS bundles often contain the full API schema — model definitions, field names, and sometimes even internal field names used in form handling code. Tools like LinkFinder and manual JS inspection can surface undocumented API fields.
API documentation leakage: Swagger/OpenAPI specs, GraphQL introspection, and inline API documentation sometimes document internal fields that shouldn't be client-accessible. Check /api-docs, /swagger.json, /openapi.json, and GraphQL introspection queries.
# GraphQL introspection to discover all object fields
{
__type(name: "User") {
fields {
name
type { name kind }
}
}
}
Common internal field names: When you can't discover the exact field names, try a wordlist of common privileged attributes:
role, isAdmin, is_admin, admin, superuser, is_superuser, staff, is_staff,
verified, emailVerified, email_verified, approved, active, isActive, is_active,
balance, creditBalance, credit_balance, credits,
subscriptionTier, subscription_tier, plan, tier,
permissions, scope, scopes,
passwordHash, password_hash, apiKey, api_key,
ownerId, owner_id, userId, user_id, organizationId
Step 3: Test each candidate property
For each candidate field, craft a request that includes it and observe:
- Does the server return an error? (Field rejected — may indicate filtering)
- Does the server return 200 but ignore the field? (Field ignored — likely safe)
- Does the server reflect the modified field in the response? (Potentially accepted)
- Does a subsequent GET request show the modified field value? (Definitive — mass assignment confirmed)
# Test for admin privilege escalation via mass assignment
# Step 1: Check current role
curl -sH "Authorization: Bearer $TOKEN" https://target.com/api/users/me
# {"id": 42, "role": "user", ...}
# Step 2: Try to set role via profile update
curl -s -X PUT https://target.com/api/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"bio": "updated", "role": "admin"}'
# Step 3: Re-check role
curl -sH "Authorization: Bearer $TOKEN" https://target.com/api/users/me
# If role is now "admin" — mass assignment confirmed
Mass Assignment in GraphQL
GraphQL mutations are susceptible to mass assignment when input types include fields that shouldn't be user-settable. The introspection-first approach applies here: discover all fields on the mutation input type, then test which ones are accepted and persisted.
# Check what fields UpdateUserInput accepts
{
__type(name: "UpdateUserInput") {
inputFields {
name
type { name kind }
}
}
}
# If isAdmin or role appear in the input fields, test them:
mutation {
updateUser(input: {
bio: "test"
isAdmin: true
role: "admin"
}) {
id
isAdmin
role
}
}
Unlike REST, GraphQL mutations are explicit about accepted fields in the schema — if a field appears in the input type, it was intentionally added (or accidentally left in). Introspection being enabled in production is itself worth flagging, as it exposes the full schema to potential attackers.
Nested Object Mass Assignment
Mass assignment isn't limited to top-level fields. Nested objects can expose sensitive inner attributes:
# Surface-level request:
PUT /api/orders/123
{"quantity": 5}
# Nested mass assignment attempt:
PUT /api/orders/123
{
"quantity": 5,
"pricing": {
"unitPrice": 0.01,
"discount": 99
},
"payment": {
"status": "completed",
"verified": true
}
}
Cart and checkout flows deserve particular attention here — the impact of successfully overriding pricing or payment status is direct financial fraud.
Mass Assignment in File Uploads
Multipart form upload endpoints are also susceptible. When a file upload form posts metadata alongside the file, additional form fields that aren't rendered in the UI can be injected:
POST /api/documents/upload
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="report.pdf"
[file contents]
------Boundary
Content-Disposition: form-data; name="isPublic"
true
------Boundary
Content-Disposition: form-data; name="ownerId"
1 # Override the owner to another user's ID
------Boundary--
Common Impact Scenarios
| Modified field | Impact |
|---|---|
role / isAdmin |
Privilege escalation — full admin access |
emailVerified |
Bypass email verification requirement |
subscriptionTier / plan |
Access paid features without payment |
creditBalance |
Set arbitrary account balance |
ownerId / userId |
Transfer ownership of objects to attacker |
price / discount |
Set arbitrary pricing — fraud |
payment.status |
Mark unpaid orders as paid |
approved |
Bypass approval workflows |
Remediation
The fix is always the same: explicit allowlisting of user-settable fields at the API layer.
- Use framework protections — Rails Strong Parameters, DRF
read_only_fields, Laravel$fillable. Know your framework's approach and use it consistently. - Input DTOs — Define separate Data Transfer Objects for API input that only contain the fields users should set. Never bind the request body directly to a domain object.
- Explicit field selection in ORMs — When updating records, select which fields to update explicitly. Avoid
update(request.body)patterns. - Schema validation at API boundaries — Use JSON Schema or equivalent to reject requests containing unexpected fields (with
additionalProperties: false). - Separate read and write models — A
UserCreateRequesttype with only user-settable fields is safer than aUsermodel that's used for both reading and writing.
Mass assignment vulnerabilities surface in API scanning when responses reveal object schemas with more fields than the API documentation acknowledges. Ironimo maps your API surface and tests endpoints for parameter injection, including mass assignment patterns across REST and GraphQL APIs.
Start free scan