Back to Cheat Sheets

📝 Mass AssignmentAPI 2019

OWASP API Security Top 10 2019 - API6

HIGH RISK

📋 What Is It?

Mass Assignment occurs when an API binds client-supplied input directly to the properties of an internal object - a database model, a domain entity, a configuration record - without an allow-list of the fields a client may actually set. The framework convenience that makes modern code pleasant (map the whole body onto an object in one line) becomes a vulnerability the moment that object holds server-controlled fields.

The attack is disarmingly simple: look at a legitimate request, guess the names of extra properties, and add them to the body. If binding is blind, the attacker has just written a field the UI never exposed - role=admin, isVerified=true, an inflated balance, or a re-pointed user_id.

API6 OWASP Rank 2019
Write Side of BOPLA
One field To Privilege

⚠️ Common Exploits

  • Privilege Fields: appending "role":"admin" or "isAdmin":true
  • Verification Bypass: "isVerified":true, "emailConfirmed":true
  • Financial Fields: "balance", "discount", "credits", "price"
  • Ownership Re-pointing: "user_id"/"tenant_id" set to someone else
  • Nested Objects: deep binding through related models
  • Timestamps & State: createdAt, status, approvedBy
  • Prototype Pollution: __proto__ / constructor keys in JS merges

🔴 Attack Flow

1. Capture a legitimate create/update request
↓
2. Learn the object's real field names - GET responses, docs, spec, errors
↓
3. Append the sensitive fields to the request body
↓
4. Re-read the object to see which extras were persisted
↓
5. IMPACT: Self-granted privileges, altered balances, hijacked ownership!

❌ Vulnerable Code

# Bad: every key in the body becomes a column @app.route('/api/users', methods=['POST']) def create_user(): user = User(**request.json) ← role, is_verified, balance all bindable db.session.add(user); db.session.commit() // Bad: blanket merge of the request body onto the entity app.patch('/api/profile', async (req, res) => { Object.assign(user, req.body); ← also reachable: __proto__, isAdmin await user.save(); }); # Bad: ownership taken from the payload order = Order(**request.json) ← "user_id": 41 → order created for someone else // Bad: an allow-list that drifts - new columns are exposed by default const DENY = ['password']; ← deny-lists always miss the next field added

✅ Secure Code

# Good: explicit allow-list - anything else is ignored or rejected ALLOWED = ('username', 'email', 'password') @app.route('/api/users', methods=['POST']) def create_user(): data = {k: v for k, v in request.json.items() if k in ALLOWED} user = User(**data) user.role = 'user' # server-controlled fields set by the server user.is_verified = False # Good: a schema that refuses unknown keys outright class CreateUser(BaseModel): model_config = ConfigDict(extra='forbid') # 422 on any extra field username: str email: EmailStr password: SecretStr // Good: pick the fields; never spread the body const { displayName, avatarUrl } = req.body; await User.update({ displayName, avatarUrl }, { where: { id: req.user.sub } }); // Good: ownership always from the token, never from the payload const order = await Order.create({ ...safeFields, userId: req.user.sub }); // Good: separate read and write shapes per role const writable = req.user.isAdmin ? ADMIN_WRITABLE : USER_WRITABLE;

✓ Prevention Checklist

  • Bind an explicit allow-list of writable fields - never the whole body
  • Prefer schemas that reject unknown keys (extra=forbid, additionalProperties: false)
  • Use dedicated request DTOs instead of database models
  • Set identity, ownership, role, status, and timestamps on the server
  • Define writable sets per role and per operation (create vs update)
  • Avoid Object.assign/deep-merge on untrusted input; block __proto__
  • Never trust a deny-list - new columns default to exposed
  • Validate nested objects with the same rigour as the top level
  • Log and alert when unexpected fields arrive
  • Test by appending sensitive fields and asserting they are not persisted

🔍 Detection & Tools

Validation:

Pydantic Marshmallow Zod / Joi JSON Schema

Testing:

Burp Suite Postman/Newman Schemathesis Semgrep rules

How to Test:

  • Add role, isAdmin, balance, user_id to create/update bodies
  • Re-read the object - did anything extra stick?
  • Harvest field names from GET responses, errors, and the OpenAPI spec
  • Try nested paths ("profile": {"tier": "gold"})
  • Send __proto__/constructor keys against JS merge code

🌍 Real-World

These are documented incident classes - recurring patterns rather than single CVEs.

  • Self-granted administrator: a registration or profile-update endpoint binds a role field the UI never sends, and the account comes back as admin.
  • Verification bypass: setting isVerified/emailConfirmed in the body skips the whole confirmation flow.
  • Ownership hijack: passing another user's identifier on create re-points a record - invoice, ticket, or device - at the victim.
  • Commerce manipulation: price, discount, or loyalty-point fields accepted from the client at checkout.
  • Prototype pollution: deep-merge helpers on untrusted JSON corrupt object prototypes and change downstream behaviour.

📌 Quick Tips

  • DO NOT pass the request body into a model constructor
  • DO NOT rely on a deny-list of "dangerous" fields
  • DO allow-list writable fields per role and operation
  • DO set ownership and privilege server-side
  • DO reject unknown keys with a 4xx

📜 Compliance

Related Standards:

  • OWASP API Security Top 10 2019 - API6
  • Merged into API3:2023 Broken Object Property Level Authorization
  • CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes
  • CWE-1321 Prototype Pollution
  • CWE-639 Authorization Bypass Through User-Controlled Key