Skip to content

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 like isAdmin, role, passwordResetToken, or accountBalance.

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 from source to target with no filtering, so every key in req.body lands 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.body first, then setting userId, means all other client-supplied properties (including status, price, isPaid) are included. Only userId is 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 to User.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:

  • createUserSchema is an explicit allowlist of fields. By default, Zod's parse() strips unknown keys, so any property not declared in the schema is silently removed before the data reaches User.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 to findByIdAndUpdate contains 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 null values 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 } from req.body on the create-user route but leaving a separate PATCH /users/:id/admin or bulk-import route that still does User.create(req.body) or Object.assign(user, req.body) - the explicit destructuring only protects the one handler it was written in.
  • Defining a Zod/Joi schema that omits isAdmin/role for the request body, but then spreading the original req.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 only isAdmin is recovered, and { isAdmin: false, ...req.body } gives even that back, because later keys overwrite earlier ones. Spread validated, never req.body.
  • Using Sequelize's fields option on instance.update() to restrict which columns can change, but calling Model.create(req.body) for the initial insert without the equivalent restriction - fields restricts create() the same way it restricts update(), so this is a case of the fix not being extended to the create path, not a limitation of the option itself. Add the same fields option (or an explicit field list) wherever the model is created.
  • Relying on a Mongoose schema's select: false on isAdmin to hide it from query results as if that also blocked writes - select: false only affects whether the field is returned by default on reads; it does nothing to prevent User.create(req.body) from setting the field if isAdmin is present in the request body.

Additional Resources