Node.js Security Tips to Avoid Vulnerabilities in Production

In recent years, Node.js has become increasingly popular as a JavaScript runtime technology that enables developers to create high-quality, robust, and scalable applications. According to the highly respected CVE database, security vulnerabilities in Node.js hit an alarming high in 2017. The number of known vulnerabilities has decreased since then, but Node.js continues to harbor security risks as development continues, as with any dynamically evolving software.

This blog posting lays out Node.js security tips that will help you avoid vulnerabilities in production.

Secure Coding Principles and Practices

A Security Coding philosophy goes far beyond just a few practices. It involves comprehensive, almost obsessive checks for vulnerabilities in code. In security coding, there is a combination of defense in depth (combining higher-level security checks with lower-level security checks) and least privilege (allowing programs to use the smallest amount of resources necessary).

To be successful, it is essential for the coding process and testing to follow Secure Coding principles and practices from the outset. A secure approach should be taken not only for your application but also for your execution environment. Operating systems, hosting environments, and networks all play a role in application security.

Start by securing your platform

There are various sources of security vulnerabilities. Below are tips that can help you mitigate their exploitation risk.

Run a security audit to ensure you are importing secure packages

Security audits assess package dependencies for security vulnerabilities. You can identify and fix known vulnerabilities in dependencies by running security audits.

Luckily, the Node.js core is supported by a large community that takes security seriously. Node.js Security Working Group (SWG) works to improve the security of applications built using Node.js. The group brings vulnerability data from the community to the Node Security Platform and maintains data on disclosed security vulnerabilities. Additionally, the SWG ensures public disclosure of Node packages' vulnerabilities.

Typically, you want to run the most recent dependency version because developers release new versions to fix bugs—including security flaws—in older versions. An audit tells you where vulnerabilities appear in the currently used packages. To search for flaws in NPM packages, run the following command:

npm audit

Use SAST and SCA tools in build pipelines

Static Application Security Testing (SAST) and Software Composition Analysis (SCA) can play an essential role in your build pipeline. These tools can detect security vulnerabilities early in development, helping you avoid costly security breaches and ensure that your applications are secure.

In addition to your build pipeline, you should consider using SAST and SCA in your continuous integration and delivery (CI/CD) processes. Using such tools helps you ensure that your applications are always secure.

Some great SAST tools for Node.js include

  • Semgrep
  • eslint-plugin-security
  • vuln-regex-detector

Don't hardcode secrets

If your Node.js application has any secrets (such as API keys or passwords), keep them separate from the source code. Anyone with access to your source code can also get access to secrets in them.

There are a few different ways to store secrets securely in Node.js applications. One way is to use environment variables, which is recommended when working with containers. This can be accomplished using the dotenv package. Another way is to use an external key manager such as Vault. We recommend using a key manager in production environments for a few reasons:

  • A key manager provides higher security for secrets and is designed specifically to store, manage, and organize them. This means that secrets are far less likely to be compromised than if they were stored somewhere less secure.
  • Key managers can provide additional features and functionality, such as regularly rotating secrets. These practices can further protect secrets.
  • The use of a key manager simplifies the overall process of managing secrets within an application, making it easier to track and ensure their proper use.

Run node.js as a non-root user; audit and monitor non-root user actions

There are a few reasons why you might want to run node.js as a non-root user. For one, it can help improve security by reducing the potential attack surface. It can also help resource management by preventing a single user from monopolizing resources.

By auditing and monitoring non-root user actions, you can prevent malicious use of the Node.js process. It can also help identify potential issues with the Node.js process itself.

Best practices to keep Node.js applications secure

Several best practices can help keep your Node.js application secure.

Prevent query injection attacks

Web applications must always validate user input, as user input can come from untrusted sources and could be malicious and jeopardize the application’s security.

Web applications are vulnerable to query injection attacks, which involve injecting malicious code into a database query via user input, such as through a web form.

Attackers can inject malicious payloads in input fields, leading to attacks such as XSS, SQL injection, etc. Attackers can use injection attacks to alter data, bypass security controls such as authentication, and gain unauthorized access to sensitive information.

When submitting data via a form in a web application, deserialization is often necessary to convert the data from a serialized format to its native format.

const passport = require('passport'); 

// Serialize the user object using PassportJS
passport.serializeUser(function(user, done) {
done(null, user._id);
});

// Deserialize the user object using PassportJS
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});

An attacker can deserialize data with cross-site request forgery (CSRF) attacks if not secured. By tricking a user into submitting a malicious request that the server executes without their knowledge or consent, a CSRF attack occurs. Only trusted and verified data should be deserialized.

Packages known as sanitizers can check for and remove malicious content from user input. The following example shows the use of such a sanitizer:

// Sanitize user input (UserID and Password) 
var sanitizer = require('sanitize')();

var userID = sanitizer.value(req.body.userID, 'string');
var password = sanitizer.value(req.body.password, 'string');

Another way to prevent query injection attacks is to use Object Relational Mapping (ORM) or Object Document Mapping (ODM) libraries. These libraries abstract away the database layer so that user input is never directly inserted into a database query. This can help to prevent malicious code from being injected into the query. For instance, the following example inserts user input into a database through the Sequelize library:

// Use Sequelize(ORM) to run a query to our database 
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'mysql'
});

// Add a new user to the database
const User = sequelize.define(‘User’,{ firstName: DataTypes.STRING, lastName: DataTypes.STRING});
User.create({ firstName: 'John', lastName: 'Doe' });
// Delete everyone named "John"
User.destroy({
where: {
firstName: 'John'
}
});

A reverse proxy can add another layer of security by screening input before invoking your application’s method.

Avoid using shell commands based on user input

One of the most common mistakes made when using shell commands (generally through the JavaScript eval function) is to base the commands on user input.

For example, consider a situation where you must list all the files in a directory. If you use the ls command with the user-specified directory as an argument, there is a chance that the user might input a malicious string for the directory name and delete all the files on your system. To avoid this, always use absolute paths when using shell commands.

Remove unnecessary routes to keep the attack surface area limited

One way to reduce the attack surface area of an application is to remove unnecessary routes. For example, if an application has a route used only for debugging purposes, it can be removed from production. As a result, attackers will not be able to exploit that route in the future.

If you want to reduce the attack surface area, you can limit the permissions of each route. For example, if administrators should only use a route, you can configure it for only administrators. As a result, non-administrators are blocked from using and exploiting the route. In the example below, if the environment is production, and the user is an administrator, then the route is executed:

// Remove /docs route in production 
if (process.env.NODE_ENV === 'production') {
app.get('/admin', isAdmin, function(req, res) {
res.send('Secret Admin Things')
});
}

Handle errors safely

To prevent unauthorized attacks, it is important to handle errors safely and securely. Using a try/catch block in your application’s middleware is one way to handle errors in Node.js safely. A library such as winston will provide a safe and secure way to log errors. It will catch errors and prevent them from being passed on to the client.

Limit request sizes to reduce DoS attacks

One way to reduce the risk of Denial-of-Service (DoS) attacks is to limit the size of requests your server will process. Alternatively, a rate-limiting system can throttle requests that come in too quickly by setting a maximum size limit on incoming requests.

Limiting the size and frequency of requests is important to prevent your server from becoming overwhelmed and forced to process substantial requests that could potentially crash it. In addition to protecting your server from DOS attacks, these limits also help to improve your application’s overall stability and performance.

For example, if a website has a rate limit of 10 requests per minute, a user who makes 20 requests will find that requests 11 through 20 are throttled or blocked. As a result, no user can monopolize the website's resources, and everyone has a fair chance to use it.

However, it is important to note that if your application uses multiple processes or servers, you must enforce these limits on your caching layer, for example using Redis.

The following example shows how easy it is to place a size limit on each request:

// Limit Request Size in Node.js 
var express = require('express')
var bodyParser = require('body-parser')

var app = express()
app.use(bodyParser.json({ limit: '2mb' }))

Rate limits can be applied to individual users, IP addresses, or even entire countries. They are important for keeping website resources available to everyone and preventing abuse. The following example imposes rate limiting:

const rateLimit = require('express-rate-limit') 

const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
})

// apply to all requests
app.use(limiter)

Ensure confidentiality of information sent back to clients

Information disclosure in computer security refers to the intentional or unintentional release of information that can lead to a security breach or harm to the organization or its users.

It is important to control what information is sent back to the client to avoid information disclosure. For example, when handling a request for sensitive information, the server should return only the necessary information and nothing more. By doing so, you can help keep sensitive information safe and avoid any potential security breaches.

Another form of controlling information disclosure is to use generic error messages that don't reveal anything about the application's internals.

Use authorization and access controls

In today's security landscape, a role-based access control (RBAC) model is essential. With the increase in cyber attacks, it is essential to have a model that provides users with the same privilege they need to perform an action and nothing more.

By mapping access control to your organizational structure, RBAC ensures that only the appropriate users have access to the data and resources they need. This protects the organization's data and helps prevent users from accidentally accessing data they should not have access to.

Middleware is a great way to handle authentication and access controls in web applications. Using middleware, you can easily add authentication and access control features to your application without writing much code.

If you need to control access to certain application parts, you can use the express-acl module to specify which roles have access to which resources. For example, you could allow only admin users to access the admin section of your application. The following code validates whether the user is an admin role user before providing access to a route:

// Middleware to check if the JWT is valid and if the user is authorized to access the route 
const jwt = require('jsonwebtoken');
const config = require('../config'); // Config with JWT Secret

module.exports = (req, res, next) => {
try {
const token = req.headers.authorization.split(' ')[1];
const decodedToken = jwt.verify(token, config.jwtSecret);
const userId = decodedToken.userId;
const { role, id } = req.body


if (role && id) {
// Verifying if the value of role is admin
if (role === "admin") {
await User.findById(id)
} else {
res.status(400).json({
message: "Role is not admin",
})
}
} else {
next();
}
} catch {
res.status(401).json({
error: new Error('Invalid request!')
});
}
}

Avoid using sequential and guessable identifiers

It is common to use sequential identifiers to identify records in a database, but this can lead to insecure direct object references (IDORs). An attacker can gain access to records they should not have access to by guessing the next identifier if they know the sequential order of the identifiers.

Let's assume the application offers a GET API to fetch user details once the user logs in. The API takes in the user ID, fetches the details from the DB for the user ID, and returns it to the client. Now, if user IDs are sequential and there is no checksum or validation in place for the user ID, the attacker can manipulate the user ID to fetch sensitive details from the API.

If you want to prevent this, use non-sequential identifiers that are hard to guess. For example, UUIDs can be used to create non-sequential identifiers:

const { uuid } = require('uuidv4'); 
const userID = uuid();
console.log(userID); // 11ff2b37-e0b8-42e0-9wcf-dc8c4aefc000

Conclusion

Security is an important aspect of Node.js application development so that your application works as expected. It's important to test your application before launching it into production and keep up with the latest security tips to avoid vulnerabilities.

We covered some of the most important aspects of Node.js security and some of the most important security practices to remember for your Node.js applications.

Was this article helpful?
Monitor your applications with ease

Identify and eliminate bottlenecks in your application for optimized performance.

Related Articles

Write For Us

Write for Site24x7 is a special writing program that supports writers who create content for Site24x7 "Learn" portal. Get paid for your writing.

Write For Us

Write for Site24x7 is a special writing program that supports writers who create content for Site24x7 “Learn” portal. Get paid for your writing.

Apply Now
Write For Us