Node.js App Security: Let No One Through the (Digital) Gates

An overview of the most common security issues unique to Node.js and their countermeasures.

Published on Apr. 06, 2022
Brand Studio Logo

Anything on the web is prone to attacks. No one can make picture-perfect applications within the web, but we can try our best to reduce the loopholes, vulnerabilities, and weaknesses in our application so the possibility of having a security breach will be minimized.

There are general abstract security measures that we can take to improve the security of web applications, and also there are technology-specific measures that we have to implement to ensure safety.

Since Node.js is used in many web applications around the world, I thought about writing on measures that are specific and unique to Node.js — and also about general security measures we can take.

The Open Web Application Security Project (OWASP) lists out some measures and recommendations we can take into consideration. They’ve categorized these measures into four main fields.

The 4 Types of Security Checks, According to OWASP

  1. Application security
  2. Error and exception handling
  3. Server security
  4. Platform security

 

1. Application Security

Application security mainly focuses on how to secure the runtime of the application from threats like unauthorized access, modification, etc. The following are some of the measures we can employ to ensure application security.

 

Limit Request Sizes

When our application is serving a user, it always has to buffer and parse some data sent by the user.

The data may be a URL, a JSON body, or multipart data. If there’s no limit to the sizes of these requests, attackers can send in large chunks of data to our application, and this may lead to the server running out of memory, out of CPU power, or out of disk space. So we need to have an upper limit on the request sizes sent from users. But URL data is very small and is measured in near-bytes or a few kilobytes. JSON data is around some kilobytes, and multipart data can go up to 20-30 megabytes.

These sizes depend upon the application use cases, usage, and the domain. Therefore, we need to manually specify different request limits for the different request types. We can easily do this for Express using the following steps.

​app.use(express.urlencoded({ limit: “1kb” })); // these limits can be altered to your usage

app.use(express.json({ limit: “1kb” }));

app.use(express.multipart({ limit:”10mb” }));

app.use(express.limit(“5kb”)); // this will be valid for every other content type

But this approach isn’t enough because attackers can manually change the request size headers and bypass this validation. Therefore, upon processing the request, we need to manually check for this validation, too.

 

Avoid Creating Race Conditions That Impact Security

For blocking events, Node.js allows assigning callbacks to maximize the throughput. For I/O-related tasks, using callbacks will help in not blocking the event loop. Take a look at this very simple but quite faulty authentication script.

const fs = require('fs');
fs.readFile('/authentication.txt', (err, data) => {
  // username = data.username;
  // password = data.password;
  // if username & password is valid -> authorize user
});
fs.unlinkSync(‘/authentication.txt’);

The above-shown code is an example of using a callback to read a file. Here, a race condition occurs between the callback and line seven. The winner of the race will completely change the output of the program flow. So if line seven gets executed first, the text file will be unlinked before the user gets authenticated. This may lead to serious faults or exceptions being thrown.

The following is a good way to perform the above task without any race conditions. We can write all of the operations that rely on each other in one nonblocking function. With this, we can guarantee the important operations are executed in the correct order.

const fs = require('fs');
fs.readFile('/authentication.txt', (err, data) => {
  // username = data.username;
  // password = data.password;
  // if username & password is valid -> authorize user
  // else -> false
  fs.unlink('/authentication.txt', (err) => { // unlink is an asynchornous delete function
    if (err) throw err;
  });
});

 

Validate Inputs 

Getting input from users is common in almost all of the web applications, so it is crucial those input values are validated. Not doing input validation or partially validating the input may lead to many different types of application vulnerabilities. Attackers can use these vulnerabilities to perform SQL injection, cross-site scripting (XSS), command injection, local/remote file inclusion, denial of service (DOS), directory traversal, LDAP injection, and many other injection attacks.

Defining SQL and LDAP Injections

  • A SQL injection attack consists of insertion or “injection” of a SQL query via the input data from the client to the application. SQL commands are injected into data-plane input in order to affect the execution of predefined SQL commands. (Source: OWASP)
  • An LDAP (light directory access protocol) injection is an attack used to exploit web-based applications that construct LDAP statements based on user input. When an application fails to properly sanitize user input, it’s possible to modify LDAP statements through techniques similar to a SQL injection.

The best way to validate input is by allowlisting the accepted inputs. Allowlist validation involves defining exactly what’s authorized and blocking everything else. But most of the time, this is somewhat hard. Therefore, we can first check against the expected input scheme and escape dangerous inputs. You can learn more about allowlisting, blacklisting, and input validation here. In node.js, there are multiple modules available to ease validation, such as validator.js and mongo-express-sanitize. An example of using validator is:

​​import validator from 'validator';


validator.isEmail('[email protected]'); //=> true

Preventing SQL injection is somewhat different than this. Attackers can pass a SQL query hidden inside a user input to the program. These queries can read our sensitive information from the database as well as result in our database being wiped out. Consider the following SQL query.

select username, password from users where id=$id

Assume an instance where the user inputs 2 or 1=1? as the input.

select username, password from users where id=2 or 1=1

This would reveal all the data from the user table to the user. To avoid these situations, we have to use parameterized queries or prepared statements. All the available database modules provide the functionality to avoid those injections. The following is from the node-postgres module.

​const Client = require(‘pg’)

const client = new Client()

await client.connect()


const query = ‘SELECT username, password FROM users WHERE id = $1’

const values = ['12213']

// callback

client.query(text, values, (err, res) => {

  if (err) {

    // error

  } else {

    // success

  }

})

Command injection is another type of attack that can happen to a web application. With this, an attacker can run OS commands on our web server. Assume an instance which uses the child_process.exec method to invoke the gzip command that appends a user-supplied dynamic file path to construct the gzip command.

​child_process.exec(

  ‘gzip ‘ + req.query.file_path,

  function (err, data) {

    console.log(‘data: ‘, data);

  }

);

Here, if an attacker appends ; rm -rf / to the file_path input, it’ll break out of the gzip command and execute the deletion command, which will wipe out the server completely. Attackers can chain multiple commands by using characters like ;, &, &&, |, ||, $(), <, >, and >>.

The reason for this is the exec method spawns a new bin/sh process and passes the commands to execute in the system shell. This is equivalent to giving the attacker a Bash interpreter with the same privileges that our application has.

So one way to prevent this is to decrease the privileges of our application. But a good way to avoid these is to use the methods execFile or spawn instead of exec.

​child_process.execFile(

 ‘gzip’,

 [file_path],

 function (err, data) {

  console.log(data);

 }

);

The commands appended to the file_path input end up in the execFile method’s second argument of type array. Any malicious commands in user input are simply ignored or cause a syntax error if they’re not relevant to the target command. But these methods are also not 100 percent safe, and we have to allowlist some user input, which I mentioned earlier, to guarantee security.

Find More Programming Tutorials on BuiltIn.com14 Hands-On Tutorials for Programming Languages

 

Perform Output Escaping

This should be performed to avoid another type of injection attack named cross-site scripting (XSS), which I mentioned earlier. Here, the attacker stores malicious code in a web application by inputting malicious scripts into forums, message boards, and comments. And these scripts get transported to another victim when they visit the web page. The malicious code executes in the victim’s browser without their knowledge.

What Is a Cross-Site Scripting (XSS) Attack?

Cross-site scripting, often abbreviated as XSS, is a type of attack in which malicious scripts are injected into websites and web applications for the purpose of running on the end user’s device. During this process, unsanitized or unvalidated inputs (user-entered data) are used to change outputs. (Source: PTsecurity.com)

Here, in addition to validating the input from the users, we have to escape all of the HTML and JavaScript content shown to the user by our application. We can use the escape-html or node-esapi libraries to perform this task in node.js.

 

Log Application Activities

Logging isn’t a way to prevent attacks. But if our server gets attacked, we can use the logs to monitor what happened and identify the loophole the attacker used. Node.js has some modules, like winston or bunyan, to log application activity.

 

Monitor the event loop for heavy traffic

When our server is overwhelmed with traffic, our application may crash. Attackers can use this to load our server with traffic and make it crash. This is a type of denial of service (DoS) attack.

What Is a Denial-of-Service (DoS) Attack?

A denial-of-service (DoS) attack is a malicious attempt to overwhelm a web property with traffic in order to disrupt its normal operations. A DoS attack is characterized by using a single computer to launch the attack. (Source: Cloudflare.com)

So we have to monitor  heavy traffic, and if it crosses a certain threshold that our application can handle, we have to respond with “Server Too Busy” to the rest of the requests. This way, our application won’t crash and will stay responsive under extreme load, and it’ll continue serving as many requests as possible.

We don’t have to do this manually. The toobusy-js module helps us do this easily.

​var toobusy = require(‘toobusy-js’);

var express = require(‘express’);

var app = express();

app.use(function(req, res, next) {

    if (toobusy()) {

        res.send(503, “Server Too Busy”);

    } else {

    next();

    }

});

 

Prevent Brute-force Attacks

Brute-force attacks mostly happen for login endpoints. Attackers can input thousands of random passwords to guess the correct password. They may be successful after thousands of tries and will be able to log into our server.

To prevent this, we can reduce the number of times a certain user can try to log in (using rate-limiter); we can lock the account of the user after several tries (using modules like Mongoose); we can gradually increase the response time for every login request from a certain user (using express-bouncer); we can also use CAPTCHAs to stop these attacks (using svg-captcha); we can potentially use hydra, a proof-of-concept tool to test how our application behaves in these scenarios.

Learn More About Cybersecurity, Software Devs10 Security Conferences for Software Developers

 

Prevent HTTP-parameter Pollution

Supplying multiple HTTP parameters with the same name may cause an application to interpret values in unanticipated ways. Attackers can exploit these to input validation, trigger application errors, or modify internal variables values.

There are various modules available to prevent these types of attacks. Express has a middleware, HPP, for these kinds of scenarios where, when it’s used, it’ll ignore all values submitted for a parameter in req.query and/or req.body and just select the last parameter value submitted.

 

2. Error Handling

We all know error handling is a must in any application, and we all know how to handle errors and exceptions. But the problem comes when certain sensitive details are revealed through stack traces. Stack traces aren’t vulnerabilities by themselves, but providing debugging information as a result of operations that generate errors is considered a bad practice. We should always log the errors but not show them to the users.

Writing Secure Node Code: Understanding and Avoiding the Most Common Node.js Security Mistakes, from the node.js YouTube channel

 

3. Server Security

As the name suggests, server security concerns the security of the server. It mainly focuses on the protection of data and resources held on the servers. It comprises tools and techniques that help prevent intrusions, hacking, and other malicious actions.

 

Set Cookie Flags Appropriately

The importance of the secure use of cookies can’t be understated. Cookies are used to transmit session information in web applications. But careless use of these may end up revealing sensitive information to the outside. There are built-in cookie flags we can use to prevent these problems.

  • httpOnly: Prevents the cookie from being accessed by client-side JavaScript. This is a good safety measure to be secured from XSS attacks.
  • Secure: Allows the cookie to be transmitted only if the communication protocol is HTTPS
  • SameSite: Prevents cookies from being sent in cross-site requests, which helps to protect against cross-site request forgery (CSRF) attacks

Here is some example code for cookie implementation (from OWASP).

var session = require(‘express-session’);

app.use(session({

    secret: ‘your-secret-key’,

    key: ‘cookieName’,

    cookie: { secure: true, httpOnly: true, path: ‘/user’, sameSite: true} // setting the flags

}));

 

Use Proper Security Headers

There are certain HTTP response headers that we can use to increase the security of our application. Once they’re set, they can restrict modern browsers from running into easily preventable vulnerabilities. The following are some of them:

  • Strict-Transport-Security: Enforces secure (HTTP over SSL/TLS) connections to the server
  • X-Frame-Options: Protects against clickjacking (when an attacker uses multiple transparent or opaque layers to trick a user into clicking on a button or link on another page) 
  • X-XSS-Protection: Enables the XSS filter built into most recent web browsers
  • X-Content-Type-Options: Prevents browsers from MIME-sniffing, or trying to guess which of the Multipurpose Internet Mail Extensions is present if it does not trust the declared content-type
  • Content-Security-Policy: Prevents a wide range of attacks, including XSS and other cross-site injections

To implement these, Node.js has a module, helmet, that we can use.

​var express = require(‘express’);

var helmet = require(‘helmet’);

 

var app = express();

 

app.use(helmet());
​

We can use this online checker to check whether our application has all the necessary headers.

Read More About Reading More on BuiltIn.comReading Code Is an Important Skill. Here’s Why.

 

4. Platform Security

Platform security refers to the security architecture, tools, and processes that ensure the security of an entire computing platform.

 

Keep the Packages Up to Date

The packages we use also have their loopholes and vulnerabilities. Once such a loophole is discovered, it’s only a matter of time before an attacker uses that to attack our website. But the package providers update these regularly with bug fixes and security updates. So we have to make sure we keep our platform up to date.

Also, we need to make sure we don’t use any third-party modules that have serious security issues. We can use Snyk, a tool to find and fix security vulnerabilities in open-source libraries and containers.

Be Safe From Evil Regexes

Evil regexes are some regular-expression implementations that may reach extreme situations that cause them to work very slowly. ([a-zA-Z]+)*, (a+)+, or (a|a?)+ are all vulnerable regexes, as a simple input like aaaaaaaaaaaaaaaaaaaaaaaa! can cause heavy computations.

Attackers can use the above knowledge to look for applications that use regexes containing an evil regex and send a well-crafted input that’ll hang the system. Alternatively, if a regex itself is affected by user input, the attacker can inject an evil regex and make the system vulnerable. You can read more about this in Regular expression Denial of Service — ReDoS.

To prevent this, we can use a node.js tool called safe-regex. Assume we have a script like this one taken from npm:

var safe = require(‘safe-regex’);

var regex = process.argv.slice(2).join(‘ ‘);

console.log(safe(regex));

We can input various types of regexes to the above file to check whether they’re safe.

$ node safe.js ‘(x+x+)+y’

false

$ node safe.js ‘(beep|boop)*’

true

$ node safe.js ‘(a+){10}’

false

$ node safe.js ‘\blocation\s*:[^:\n]+\b(Oakland|San Francisco)\b’

true

This may sometimes give false positives, so we have to use it with caution.

 

Conclusion

So by implementing the above security measures, we can ensure our application is safer than most of the web applications out there. But we can’t guarantee safety because each day, new ways to attack web applications come into being. So we have to stay up to date and be careful.

Read More About Improving Your Code on BuiltIn.comYour Code Is Probably Overdue for Refactoring

Explore Job Matches.