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 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 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. There is no filtering - every key in req.body is 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.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:

  • zod schema validation defines 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(). 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 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
});

Remediation Steps

Locate the Finding

  • Source: req.body, req.query, req.params passed 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 or createUserSchema.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 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.

Additional Resources