Skip to main content

Avoiding mass assignment vulnerabilities in Node.js

wordpress-sync/feature-buffer-overflow

March 28, 2023

0 mins read

Mass assignment is a vulnerability that allows attackers to exploit predictable record patterns and invoke illegal actions. Mass assignment usually occurs when properties are not filtered when binding client-provided data-to-data models. Vulnerabilities of this type allow an attacker to create additional objects in POST request payloads, allowing them to modify properties that should be immutable.

Node.js is an open source server environment that can be used cross-platform for web application development. It enables developers to build powerful and scalable applications quickly. At its core, Node.js is a battle-tested and production-ready platform — application security challenges come with supply chain security associated with third-party packages installed to build applications. Creating a Node.js application can sometimes use thousands of open source npm packages from the npm registry, opening it up to many different attack vectors. 

Mass assignment vulnerabilities are common in Node.js applications that send payloads in POST requests to the database. An attacker can use these vulnerabilities to orchestrate SQL injection attacks or other attacks directed at data contained in databases. An attacker can use a mass assignment vulnerability to take complete control of a system or steal sensitive data, making it essential to defend against this kind of attack. 

This article will demonstrate a mass assignment vulnerability in a Node.js project, how an attacker can exploit it, and ways to protect a web application against it.

Removing mass assignment vulnerabilities from Node.js

This example uses Mongoose, an Object Data Modelling (ODM) library for Node.js and MongoDB. It coordinates objects in code and objects in MongoDB, validates schema, and helps manage data relationships. 

Based on the information above, if you want to protect mass assignment from a Node.js MongoDB application, you must do it in Mongoose. Below is an example of exploiting mass assignment vulnerability in a Node.js application.

Prerequisites

To follow along with this project, you will need the following:

  • Node.js installed

  • Access to a MongoDB database and a valid connection string. This tutorial relies on a MongoDB Atlas cloud environment, but you can spin-off a local MongoDB server and update the connection string respectively.

Mass assignment in Node.js 

To get started with this project, open your terminal and run this command:

1npm init

Next, you need to install the dependencies:

1npm i mongoose
2npm i express

To handle the data in the form above, we need a newUser.js file under the models folder to handle the user schema.

1const mongoose = require('mongoose');
2
3const Schema = mongoose.Schema;
4
5const userSchema = new Schema({
6
7    first_name: String,
8    last_name: String,
9    email: String,
10    password: String,
11    isAdmin: {
12        type: Boolean,
13        protect: true,
14        default: false
15    }
16
17});
18
19const User = mongoose.model("User", userSchema);
20
21module.exports = User;
22

This model captures all the fields in the registration form. The extra field isAdmin, of type Boolean defines a user’s role. If you set this field to true, you create a user with administrator privileges, and if you set it to false, you create a user without administrator privileges.

Create a new directory called routes and add the file routes.js to handle user details:

1    const express = require("express");
2    const userModel = require("../models/newUser");
3
4    const app = express();
5
6    app.post("/add_user", async (request, response) => {
7        const user = new userModel(request.body);
8
9        try {
10          await user.save();
11          response.send(user);
12        } catch (error) {
13          response.status(500).send(error);
14        }
15    });
16
17module.exports = app;

The code above captures the data in the form, then structures it with the newUSer model. 

This code has two flaws that introduce a mass assignment vulnerability. First, it uses a common name and type for a sensitive field — isAdmin. Second, the user model contains a sensitive, unused field. To exploit the vulnerability, let’s start a server. 

Create a file named server.js in the project root folder and add the following code, replacing the <APP_ID> with the value in your connection string:

1const express = require("express");
2const mongoose = require("mongoose");
3const Router = require("./routes/routes")
4
5const app = express();
6
7app.use(express.json());
8
9const username = "<USER>";
10const password = "<USER_PASSWORD>";
11const cluster = "<CLUSTER>";
12const dbname = "<DATABSE_NAME>";
13
14mongoose.connect(
15  `mongodb+srv://${username}:${password}@${cluster}.<APP_ID>.mongodb.net/${dbname}?retryWrites=true&w=majority`,
16  {
17    useNewUrlParser: true,
18    useUnifiedTopology: true
19  }
20);
21
22const db = mongoose.connection;
23db.on("error", console.error.bind(console, "connection error: "));
24db.once("open", function () {
25  console.log("Connected successfully");
26});
27
28app.use(Router);
29
30app.listen(3000, () => {
31  console.log("Server is running at port 3000");
32});
33

Start the server by running node server.js.

A malicious actor can exploit this vulnerability by creating a curl POST request containing the isAdmin field with a value set to true, as shown in the code below:

1curl --location --request POST 'http://localhost:3000/add_user' \
2--header 'Content-Type: application/json' \
3--data-raw '{
4    "first_name": "Mike",
5    "last_name": "Jones",
6    "email": "mikejones@hmail.com",
7    "isAdmin":"true"
8}'

The request above creates a user, Mike, with admin privileges. This user can now use their credentials to log in to the system and do anything an administrator can.

Defending against mass assignment

We can make three adjustments to increase the application's defense against mass assignment attacks.

Create lean models

Lean models only include what a user is expected to add as inputs, and they exclude sensitive fields.

From the example above, the first and most essential step we can take is to remove the sensitive field from the newUser model, as shown below:

1const mongoose = require("mongoose");
2
3// create a schema
4var userSchema = new mongoose.Schema({
5	first_name: String,
6	last_name: String,
7	email: String,
8	password: String
9
10	//NB: there is no isAdmin field
11
12});
13
14const User = mongoose.model("User", userSchema);
15
16module.exports = User;

Now, if attackers gain access to the source code, they won’t be able to view the sensitive field and use it to orchestrate an attack. You should also avoid using predictable terms in the database to define a user's role — such as isAdmin, admin, and role — because an attacker may still be able to guess them and launch an attack.

Use schema validation for user input

Validating using underscore

Another way to protect your app is to specify and limit the fields a POST request can handle. This is known as allow-listing and uses the underscore library. First, install the library:

1npm install underscore

Navigate to the routes folder and replace the route.js code with this:

1const express = require("express");
2const userModel = require("../models/newUser");
3var _ = require('underscore');
4
5const app = express();
6
7app.post("/add_user", async (request, response) => {
8    const user = new userModel( _.pick(request.body, 'first_name', 'last_name', 'email', 'password'));
9
10    try {
11      await user.save();
12      response.send(user);
13    } catch (error) {
14      response.status(500).send(error);
15    }
16});
17
18module.exports = app;

In the code above, we use the pick function to specify the variables extracted from a POST request. This prevents an attacker from using the sensitive isAdmin field.

The challenge with using underscore is that the application must process the data to know whether it's valid. In other words, it doesn’t catch the errors early enough.

Validating using Zod

For robust schema validation, you should use Zod. It validates the schema assuring that the data strictly meets the specified structure, pattern, and data type. Zod identifies incomplete or incorrect data early enough to prevent application errors. Additionally, you can easily create an error message to guide the user on the inputs causing an error and the type of error.

The code snippet demonstrates how Zod works. Note that the following snippet is in Typescript, which requires some extra configuration.

1import { z, AnyZodObject } from "zod";
2import express, { Request, Response, NextFunction } from 'express';
3
4const app = express();
5
6app.use(express.json());
7
8const dataSchema = z.object({
9    body: z.object({
10        first_name: z.string({
11            required_error: "First name is required",
12            invalid_type_error: "First name must be a string",
13        }),
14        last_name: z.string({
15            required_error: "Last name is required",
16            invalid_type_error: "Last name must be a string",
17        }),
18        password: z.string({
19            required_error: "Password is required",
20        }),
21        email: z
22            .string({
23                required_error: "Email is required",
24            })
25            .email("Not a valid email"),
26    }),
27});
28
29const validate =
30    (schema: AnyZodObject) =>
31    async (req: Request, res: Response, next: NextFunction) => {
32        try {
33            await schema.parseAsync({
34                body: req.body,
35                query: req.query,
36                params: req.params,
37            });
38            return next();
39        } catch (error) {
40            return res.status(400).json(error);
41        }
42    };
43
44app.post("/create",
45    validate(dataSchema),
46    (req: Request, res: Response): Response => {
47        return res.json({
48            ...req.body
49        });
50    }
51);

If a user tries to enter data that doesn’t match the schema, they will get an error stating they’ll get an invalid_type_error. If they leave a field empty, they’ll get a required_error.

More mass assignment vulnerability examples in ORMs

Let’s look at three more examples of mass assignment vulnerabilities in common ORM solutions.

Mass assignment vulnerability in Sequelize code

Let’s look at an example of Sequelize code that’s vulnerable to mass assignment. This code defines a user model, creates a user, and saves user details to the database:

1const { Sequelize } = require("sequelize");
2
3const User = sequelize.define("users", {
4    username: {
5        type: DataTypes.STRING,
6        allowNull: false
7    },
8
9    email: {
10        type: DataTypes.STRING,
11        allowNull: false
12    },
13
14    password: {
15        type: DataTypes.STRING,
16        allowNull: false
17    },
18
19    is_verified: {
20        type: DataTypes.BOOLEAN,
21        allowNull: false,
22        default: false
23    },
24
25 });

When creating a new user, the controller calls the model above, as shown below:

1// Create a User
2const user = {
3    username: req.body.username,
4    email: req.body.email,
5    password: req.body.password,
6    is_verified: false
7  };
8
9//Save User in the database
10User.create(user)
11.then(data => {
12    res.send(data);
13})
14.catch(err => {
15    res.status(500).send({
16    message:
17        err.message || "Error occurred. User not created!"
18    });
19});

The code above is vulnerable because it exposes the sensitive field is_verified. An attacker can create a POST request with the is_verified field set as true, allowing them to bypass the verification step.

We can remove the mass assignment vulnerability in this code by removing the is_verified field when creating the user. The safe code would look like this:

1// Create a User
2const user = {
3    username: req.body.username,
4    email: req.body.email,
5    password: req.body.password
6  };

This limits the acceptable POST request variables to username, email, and password.

Mass assignment vulnerability in Prisma code

Below is an example of a mass assignment vulnerable Prisma code that exposes the sensitive field Role.

1model User {
2    id      		Int    @id @default(autoincrement())
3    username        String
4    email       	String
5    Role    		Boolean    @default(USER)
6  }
7
8enum Role {
9    USER
10    ADMIN
11}

To add a user, we create a route add_user.

1const { PrismaClient } = require("@prisma/client");
2const express = require("express");
3const prisma = new PrismaClient();
4
5const app = express();
6
7app.use(express.json());
8
9app.post('/add_user', async (req, res) => {
10    const user = await prisma.user.create({ data: req.body });
11    res.json(user);
12  });

This POST request will expect the body with a user object. The code above is vulnerable to mass assignment attacks because a hacker can add the field role to ADMIN to gain administrator privileges.

We can remove the vulnerability in the code above by excluding the sensitive field Role from the model.

1model User {
2    id      		Int    @id @default(autoincrement())
3    username        String
4    email       	String
5  }

Mass assignment vulnerability in MySQL code

Here’s an example of mass assignment-vulnerable MySQL code that exposes the sensitive field isAdmin:

1// Defining the user data
2let user = {
3    username: req.body.username,
4    email: req.body.email,
5    password: req.body.password,
6    isAdmin: req.body.role
7};

This code adds the user defined above:

1// Saving user details
2app.post('/add_user',(req, res) => {
3
4let myQuery = "INSERT INTO users SET ?";
5
6    let query = connection.query(myQuery, user,(err, results) => {
7        if(err) throw err;
8        res.send(apiResponse(results));
9    });
10
11});

The code above has a mass assignment vulnerability because it exposes a sensitive field — isAdmin — and allows us to include it in a POST request. An attacker can create a POST request that sets the isAdmin field to be true, creating an admin user.

We can fix this vulnerability by excluding the sensitive field when creating the user object. Here’s the safe code:

1// Defining the user data
2let user = {
3    username: req.body.username,
4    email: req.body.email,
5    password: req.body.password
6};

The default value of false for the isAdmin field can be set by altering the table using the following command:

1ALTER TABLE users ALTER isAdmin SET DEFAULT ‘false’;

Keeping your code safe in Node.js

Node.js is secure, but sometimes the packages required to build a web application aren’t. For Node.js applications that send requests to a database, the mass assignment vulnerability is one of the most common attack vectors. It allows malicious users to perform SQL injection attacks, which involve sending malicious requests to a database.

However, sanitizing inputs can easily prevent mass assignment in Node.js. You can do this by not exposing sensitive fields, limiting acceptable fields, or blocking the editing of some fields.

Failure to implement robust user input validation exposes a system to attacks such as prototype pollution. Such attacks can bypass weak validation mechanisms such as underscore. Therefore, always ensure you use robust solutions such as Zod.

Check out the complete source code example in our GitHub repository, and run your own local security experiments for how to avoid mass assignment vulnerability in Node.js.

Follow Snyk’s Top 10 Node.js Security Best Practices to optimize the security of your Node.js application.

Posted in: