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 spread (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.
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 fromsourcetotarget. There is no filtering - every key inreq.bodyis copied to the user object, 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:
zodschema validation defines 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(). Unknown keys never reach the model.
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
});
Remediation Steps
Locate the Finding
- Source:
req.body,req.query,req.paramspassed as object to model operations - Sink:
Model.create(req.body),instance.update(req.body),Object.assign(obj, req.body),{ ...req.body }
Apply the Fix
- PRIORITY 1: Replace
Model.create(req.body)with explicit destructuring orcreateUserSchema.parse(req.body) - PRIORITY 2: Replace
Object.assign(user, req.body)and{ ...req.body }with named field extractions - PRIORITY 3: Set server-controlled fields (
role,isAdmin,ownerId) explicitly in the code, never from the request
Verify the Fix
- Submit a request body with
{ "isAdmin": true }or{ "role": "admin" }and confirm those fields are not persisted - Confirm that valid fields still work correctly
- Rescan with the security scanner to confirm the finding is resolved
Check for Similar Issues
Search for: Model.create(req.body, instance.update(req.body, Object.assign(, { ...req.body } in route handlers
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.