Hunting mass assignment: which fields, not which object
Broken object-level authorization asks whether the caller may reach this object. Mass
assignment asks the next question: given an object the caller may write, which of its
fields may they set? The bug is a handler that binds a request payload onto a record
and trusts the client to send only the fields it should, so an attacker adds
"role":"admin", "owner_id": <someone else>, "price": 0, or "verified": true to a
payload the endpoint was never meant to accept, and the framework writes it. You find it
by listing what the client can put into the payload, following it to the record write,
and asking which of those fields the server, not the client, is supposed to own.
When to use
- You are reviewing a create or update handler that maps request fields onto a persisted object.
- The framework auto-binds or hydrates a payload onto a model, struct, or record.
- Some properties of the object are meant to be server-controlled: privilege, ownership, tenancy, money, state, or verification flags.
Scope check
Test property writes only in systems you own or are authorized to test, with accounts that let you observe a privilege, ownership, or state change take effect. If you can't name the authorization, stop.
The loop
List the writable fields the client can reach. For each create and update handler, determine how the payload becomes a record: an explicit field-by-field assignment, an allowlisted binder, or a whole-payload hydration. Whole-payload binding means every column is a candidate; a binder means whatever the allowlist admits. Enumerate the fields the client can actually set, not the ones the form shows.
Follow the payload to the record write. Trace the bound fields from the request to the persistence call, the object mutation and save. That write is the sink. Note whether the binding is an allowlist (only named fields) or a blocklist (everything except named fields); a blocklist is a standing bet that no sensitive field was ever added later.
Name the server-controlled fields. Which properties of this object must the server own, not the client:
role,is_admin, permissions,owner_id,tenant, foreign keys to other users,price,balance, credit,status,verified,created_by, timestamps? Any of these reachable through the binding is the bug. The audit is the overlap between client-writable and server-owned.Check the ways the hole reopens. A clean top-level allowlist can still leak through a nested object or relation that binds recursively, a field whose name differs from the column but maps through an alias, a second update path with looser binding, or type juggling where a string, array, or object where a scalar was expected flips a check or a flag. Enumerate nested, aliased, and sibling-path bindings, not just the flat top level.
Test the injected field. Confirm by adding the server-owned field to the payload and observing it take effect: set
role/is_adminand gain privilege, setowner_idor a tenant key and reassign or reach another party's object, setprice/balanceand move money, setverified/statusand skip a gate. On the read side, check whether the response serializes properties the caller should not see. The state change or leaked property is the proof.Confirm and record. Confirm with the request that sets a server-owned field and the observed effect. Kill the lead if every write path binds an explicit allowlist that excludes all server-owned fields, nested and relation binds are constrained the same way, and reads serialize only permitted properties. Record with the handler, the injected field, and the privilege, ownership, money, or state change it caused.
Where property authorization leaks
- Blocklist binding is a bet against the future. It protects the fields someone remembered to exclude; the sensitive column added six months later walks straight through. Allowlist per handler.
- The form is not the API. Client-writable is every field the binder accepts, not the ones the UI renders. Test fields that never appear on screen.
- Nested and relation fields reopen a clean top level. Recursive binding on an embedded object or an association is where the allowlisted handler still leaks.
- The same object has more than one write path. A strict create and a loose update, or an admin path with whole-payload binding, and the loose one governs.
- Reads leak properties too. Serializing the whole record hands the caller fields (internal flags, other users' keys, secrets) that the write side carefully guarded.
Worked example (a confirm and a kill)
Confirm.
PATCH /api/profilebinds the whole JSON body onto the user record with a blocklist that excludespassword_hashbut notrole. Sending{"role":"admin"}alongside a normal profile edit persists the new role, and the next request is privileged. Confirmed mass assignment to privilege,critical, remediation = bind an explicit allowlist of user-editable profile fields and reject or ignore everything else, with a test thatrolecannot be set through this path.Kill. Every create and update handler assigns a named allowlist of client-editable fields;
role,owner_id, tenant, money, and state columns are set only by server code; nested objects bind through their own allowlists; responses serialize an explicit view, not the record. Injecting each server-owned field has no effect and none appear in reads. Killed,kill_reason= "per-handler allowlist binding, server-owned fields never client-writable at any path including nested and update, explicit read view."
Rationalizations to reject
- "The UI never sends that field." → The API accepts it. Property authorization is about what the binder writes, not what the form shows.
- "We strip the dangerous fields." → A blocklist is only as complete as its last update. Name what may be written, not what may not.
- "Only the create path is exposed." → And the update path, the admin path, the nested bind? The loosest write path governs the object.
- "The field is validated." → Validated for type, or for who may set it? A well-typed
rolestill should not be client-writable. - "It is just a profile edit." → A profile edit that reaches
roleorowner_idis a privilege or ownership change wearing a profile edit's clothes.
Executing this in practice
You need, per write handler, the set of fields the binding actually admits (not the form fields), a trace from payload to the record write, the list of properties that must be server-owned for this object, and accounts that let you watch an injected field take effect. A dataflow view that carries request fields to the columns they write, and flags whole-payload or blocklist binding versus an explicit allowlist, turns this into a coverage pass over every write path; the list-fields, follow-to-write, name-the-server-owned method is the same by hand.
Related
hunting-broken-object-level-authorization- the sibling bug: which object the caller may reach, where this skill is which fields of it they may write.auditing-declarative-authorization- authorization expressed as config; property writes are the field-granular layer beneath it.hunting-business-logic-flaws- where a writable field (quantity, price, limit) drives an outcome the rules did not intend.finding-fail-open-flaws- a binder that admits everything by default is the fail-open shape at the field layer.- FINDING-SCHEMA.md - source = the injected payload field, sink = the record write, evidence = the privilege, ownership, money, or state change observed.