CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - JavaScript/Node.js
Overview
Mass assignment vulnerabilities in Node.js REST APIs occur when request body objects are passed directly to database model constructors or update methods without filtering. An attacker can include extra properties in the request body - such as isAdmin: true, role: "admin", or balance: 999999 - that the application was never intended to accept from users, but that get persisted to the database because all fields are passed through without restriction.
This pattern is common with Mongoose (User.create(req.body)), Sequelize (User.create(req.body)), and plain object merging (Object.assign(user, req.body)). The lack of field filtering means any field the model accepts can be overwritten by the user.
Primary Defence: Never pass req.body directly to Model.create() or model.update(). Explicitly destructure or pick only the fields you intend the user to supply, or use a schema validation library (Zod, Joi) configured to strip unknown fields.
Where the key, rather than the value, is the problem: if the request decides which property gets written - a deep merge, a set(obj, path, value) helper, a config loader walking req.body - the same payload can reach Object.prototype and change every object in the process, not one record. That is CWE-1321 (Prototype Pollution), this page's child weakness, and it needs the fixes on that page as well as the field allowlist below.
Common Vulnerable Patterns
Direct req.body to Model.create()
// VULNERABLE - attacker can set isAdmin, role, balance, or any other field
router.post('/users', authenticate, async (req, res) => {
const user = await User.create(req.body);
// Attack: POST /users with { "name": "Alice", "email": "a@a.com", "isAdmin": true }
// -> isAdmin is persisted as true
res.status(201).json(user);
});
Why this is vulnerable:
User.create(req.body)passes the entire request body object to Mongoose/Sequelize. Any field present in the model schema can be set - including security-critical fields likeisAdmin,role,passwordResetToken, oraccountBalance.
Object.assign with req.body
// VULNERABLE - req.body properties are merged into the user object
router.put('/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.id);
Object.assign(user, req.body); // Merges ALL request body fields
await user.save();
// Attack: { "name": "Alice", "role": "admin" } -> role is overwritten
res.json(user);
});
Why this is vulnerable:
Object.assign(target, source)copies all enumerable own properties fromsourcetotargetwith no filtering, so every key inreq.bodylands on the user object and is then saved.
Spread Operator with req.body
// VULNERABLE - spread includes all req.body properties
router.post('/orders', authenticate, async (req, res) => {
const order = await Order.create({
...req.body, // includes everything the client sends
userId: req.user.id, // overwritten server-side... but only userId
});
// Attack: { "price": 1, "status": "paid", "discountCode": "ADMIN100" }
// -> "status" is set to "paid" without actual payment
res.status(201).json(order);
});
Why this is vulnerable:
- Spreading
req.bodyfirst, then settinguserId, means all other client-supplied properties (includingstatus,price,isPaid) are included. OnlyuserIdis protected.
Secure Patterns
Explicit Destructuring
// SECURE - only permitted fields are extracted from req.body
router.post('/users', authenticate, async (req, res) => {
// Explicit destructuring - any other field in req.body is ignored
const { name, email, password } = req.body;
const user = await User.create({
name,
email,
password,
role: 'user', // server-controlled; not from request
isAdmin: false, // server-controlled; not from request
ownerId: req.user.id, // server-controlled; not from request
});
res.status(201).json(user);
});
Why this works:
- Destructuring with named variables means only those three fields are extracted. Any other property in
req.body(e.g.,isAdmin,role) is not referenced and therefore not passed toUser.create().
Zod Schema Validation (Strips Unknown Fields)
const { z } = require('zod');
// SECURE - schema defines exactly what the user can supply
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(12),
// 'role', 'isAdmin', 'balance' intentionally omitted - cannot be supplied
});
router.post('/users', authenticate, async (req, res) => {
// parse() throws ZodError if validation fails; strips unknown fields
const validated = createUserSchema.parse(req.body);
const user = await User.create({
...validated,
role: 'user', // server-set
isAdmin: false, // server-set
});
res.status(201).json(user);
});
Why this works:
createUserSchemais an explicit allowlist of fields. By default, Zod'sparse()strips unknown keys, so any property not declared in the schema is silently removed before the data reachesUser.create().
Explicit Pick for Updates
// SECURE - only permitted fields are updated
router.put('/profile', authenticate, async (req, res) => {
const { name, bio, avatarUrl } = req.body; // explicit allowlist
await User.findByIdAndUpdate(req.user.id, {
name,
bio,
avatarUrl,
// role, isAdmin, email, passwordHash - not updated from request
}, { new: true });
res.sendStatus(204);
});
Why this works:
- Only the three named fields are extracted from
req.body. The update object passed tofindByIdAndUpdatecontains only those three keys, so no other model fields can be modified via this endpoint.
Framework-Specific Guidance
Mongoose: Use $set for Safety
// SECURE - using $set with explicit fields prevents full document replacement
await User.updateOne(
{ _id: req.user.id },
{ $set: { name: req.body.name, bio: req.body.bio } }
);
Sequelize: Use fields Option
// SECURE - Sequelize's 'fields' option acts as an allowlist for update
await user.update(req.body, {
fields: ['name', 'bio', 'avatarUrl'], // only these fields are updated
});
Testing
- Normal input: create and update records using only allowed fields and confirm expected fields persist.
- Boundary input: submit unknown fields, nested objects, arrays, and
nullvalues to confirm schema filtering is consistent. - Malicious input: include
isAdmin,role,ownerId,balance, or other server-controlled fields; verify they are ignored or rejected and never saved.
Common Pitfalls
- Destructuring
{ name, email, password }fromreq.bodyon the create-user route but leaving a separatePATCH /users/:id/adminor bulk-import route that still doesUser.create(req.body)orObject.assign(user, req.body)- the explicit destructuring only protects the one handler it was written in. - Defining a Zod/Joi schema that omits
isAdmin/rolefor the request body, but then spreading the originalreq.body(not the validated/parsed result) into the model call by mistake -User.create({ ...req.body, isAdmin: false })validates one object and persists another, so every field the schema was meant to strip is back except the one named last. Key order decides how much gets through: with the server-set value written after the spread onlyisAdminis recovered, and{ isAdmin: false, ...req.body }gives even that back, because later keys overwrite earlier ones. Spreadvalidated, neverreq.body. - Using Sequelize's
fieldsoption oninstance.update()to restrict which columns can change, but callingModel.create(req.body)for the initial insert without the equivalent restriction -fieldsrestrictscreate()the same way it restrictsupdate(), so this is a case of the fix not being extended to the create path, not a limitation of the option itself. Add the samefieldsoption (or an explicit field list) wherever the model is created. - Relying on a Mongoose schema's
select: falseonisAdminto hide it from query results as if that also blocked writes -select: falseonly affects whether the field is returned by default on reads; it does nothing to preventUser.create(req.body)from setting the field ifisAdminis present in the request body.