Mass Assignment
If an API copies every field in the request body onto the record, a client can set fields it was never meant to control: a role, a price, a paid flag. The scanner adds one such field to a write request and reads the reply. If the field comes back with the value that was sent, the server took it.
One field per request, every write endpoint
The scanner does not need to know what the endpoint expects. It picks every endpoint that writes, sends a body that contains only an escalation field, and checks whether the server echoes it back with the same value in a JSON response. A control request with a made-up field detects echoing endpoints; for those the scanner reads the written object back, keeps High only for fields that really persisted, and otherwise collapses the reflections into one Low note. HTML pages are never evidence.
1Pick the writes. Every POST, PUT and PATCH endpoint in the inventory is a candidate. GET endpoints are skipped. If the inventory holds no write endpoint the scanner stops without sending anything.
2One field at a time. Seven requests per endpoint, each with a JSON body holding only that field. The Prefer: return=representation header asks PostgREST-style APIs to send the row back. When the URL contains supabase or rest/v1 the list is replaced by three fields, not extended.
3Read the reply. The server answers. A non-2xx status ends the check for that field straight away, so a rejected request can never become a finding.
4The decision. The status is 200 to 299 and the field appears in the decoded body with a matching value. The finding is High with confidence 0.85, whatever the field, and it quotes the request and the status as evidence.
Finding the field when the API wraps its reply
Most APIs wrap the object they just created: {"status": "success", "data": {...}}, {"result": {...}}, or JSON:API's {"data": {"attributes": {...}}}. A check that only reads the top level calls every one of those clean. That is how a confirmed, reproducible mass assignment once reported as no finding.
1Top level. The scanner sent {"role": "admin"}. The root object has status and data, no role. A top-level-only check would end here.
2Descend. Instead of guessing envelope names such as data or result, the scanner walks every value that is an object or a list. Next level: data holds user.
3Found, and the value matches. role is inside user and equals "admin". That is the evidence: the server stored or at least echoed what the client chose.
4The value is part of the test. If the same field came back as "customer", the server ignored the input and set its own default. Finding the key somewhere proves nothing, so this is not reported.
5Comparison rules. Booleans must come back as a boolean, other values are compared as strings so a number and its quoted form agree, and the walk stops at 8 levels. A body that is not JSON falls back to a text search for both the name and the value.
What it cannot reach yet: endpoints that validate first
The probe sends the escalation field on its own, with no valid base payload. An endpoint that checks its required fields rejects that request outright, so the escalation is never evaluated. The Juice Shop benchmark shows both sides of this.
1A bare body is enough. POST /api/Users accepts a body that carries only the escalation field, so the server creates the record, echoes role: admin, and the scanner reports it. This is the Juice Shop registerAdminChallenge.
2Validation gets there first. POST /api/Feedbacks also demands a captcha and its own required fields. The same bare body is refused before any assignment happens, the status is not 2xx, and the scanner moves on.
3A real gap, stated openly. A clean result means "no bare-body escalation", not "no mass assignment". Endpoints with mandatory fields need a valid base payload that the probe does not yet supply.
Why it matters
If your API blindly accepts all fields from the request body, an attacker can promote themselves to admin with {"role": "admin"} or {"is_admin": true}, set {"price": 0.01} on an order update, change ownership with {"user_id": "other-user"}, or mark an order paid with {"status": "paid"} without paying.
GitHub (2012). Egor Homakov exploited a Rails mass assignment vulnerability to add his SSH key to the Rails core repository by manipulating the user_id attribute, gaining commit access to one of the most important open source projects. HackerOne (2015). A mass assignment flaw allowed adding yourself as a member of any bug bounty program by manipulating group membership parameters.
Vulnerable vs fixed code
The vulnerable handler writes whatever the client sent. The fix names the fields a client may set and leaves everything else out, so role and is_admin can only be changed by code that decides to.
// BAD: Accept all fields from request body
const body = await request.json()
await db.update("profiles", userId, body) // Whatever the client sends gets written// GOOD: Explicitly pick allowed fields
const body = await request.json()
const allowedUpdate = {
display_name: body.display_name,
avatar_url: body.avatar_url,
// role and is_admin are NOT included
}
await db.update("profiles", userId, allowedUpdate)Limits
The scanner checks reflection in the reply. It does not fetch the record afterwards to see whether the value was stored, and the probe itself is a real POST, PUT or PATCH that may create or change a record. The severity is always High at confidence 0.85, whatever the field.
| Setting | Value |
|---|---|
| Endpoints tested | POST, PUT, PATCH |
| Fields per endpoint | 7 (3 for Supabase-style URLs) |
| Success status | 200 to 299 |
| Search depth in the reply | 8 levels |
| Authentication | session headers added when the scan is logged in |