A little bit about Node.JS security by hands

Ivan Piskunov
9 min readApr 5, 2023

This short doc intend provide a simple guidelines on how to secure a Node.js application.

1. Code Security

1.1 SQL Injection Attack

SQL injection is an exploit where malicious users can pass unexpected data and change the SQL queries. Let’s understand with the example. Assume your SQL query looks like this:

UPDATE users
SET first_name="' + req.body.first_name + '" WHERE id=1332;

In a normal scenario, you would expect that this query will look like this:

UPDATE users
SET first_name = "John" WHERE id = 1332;

Now, if someone passes the first_name as the value shown below:

John", last_name="Wick"; --

Then, your SQL query will look like this:

UPDATE users
SET first_name="John", last_name="Wick"; --" WHERE id=1001;

If you observe, the WHERE condition is commented out and now the query will update the users table and sets every user’s first name as “John” and last name as “Wick”. This will eventually lead to system failure and if your database has no backup, then you’re doomed.

How to prevent

The most useful way to prevent SQL injection attacks is to sanitize input data. You can either validate every single input or validate using parameter binding. Parameter binding is mostly used by the developers as it offers efficiency and security.

var mysql = require('mysql');
var connection = mysql.createConnection({
host : 'localhost',
user : 'me',
password : 'secret',
database : 'my_db'
});
connection.connect();connection.query(
'UPDATE users SET ?? = ? WHERE ?? = ?',
['first_name',req.body.first_name, ,'id',1001],
function(err, result) {
//...
});

The double question mark is replaced with the field name and the single question mark is replaced with the value. This will make sure that input is safe. You can also use a stored procedure to increase the level of security but due to lack of maintainability developers tend to avoid using stored procedures. You should also perform the server-side data validation. I do not recommend you to validate each field manually, you can use modules like joi.

1.2 Password Hashing

Hashing is a function that generates a fixed-size string from input. The output from the hashing function cannot be decrypted hence it’s “one-way” in nature. For data such as passwords, you must always use hashing algorithms to generate a hash version of the input password string which is a plaintext string.

Well, as I mentioned above, hashing takes an input string and generates a fixed-length output. So attackers take a reverse approach and they generate the hashes from the general password list, then they compare the hash with the hashes in your system to find the password. This attack is called lookup tables attack.

This is the reason why you as an architect of the system must not allow generic used passwords in your system. To overcome this attack, you can something called “salt”. Salt is attached to the password hash to make it unique irrespective of the input. Salt has to be generated securely and randomly so that it is not predictable. The Hashing algorithm we suggest you is BCrypt. At the time of writing this article, Bcrypt has not been exploited and considered cryptographically secure. In Node.js, you can use bcyrpt node module to perform the hashing.

Please refer to the example code below.

const bcrypt = require('bcrypt');
const saltRounds = 10;
const password = "Some-Password@2020";
bcrypt.hash(
password,
saltRounds,
(err, passwordHash) => {
//we will just print it to the console for now
//you should store it somewhere and never logs or print it
console.log("Hashed Password:", passwordHash);
});

The SaltRounds function is the cost of the hash function. The higher the cost, the more secure hash would be generated. You should decide the salt based on the computing power of your server. Once the hash is generated for a password, the password entered by the user will be compared to the hash stored in the database. Refer to the code below for reference.

const bcrypt = require('bcrypt');
const incomingPassword = "Some-Password@2020";
const existingHash = "some-hash-previously-generated"
bcrypt.compare(
incomingPassword,
existingHash,
(err, res) => {
if(res && res === true) {
return console.log("Valid Password");
}
//invalid password handling here
else {
console.log("Invalid Password");
}
});

1.3 Password Storage

Whether you use the database, files to store passwords, you must not store a plain text version. As we studied above, you should generate the hash and store the hash in the system. I generally recommend using varchar(255) data type in case of a password. You can opt for an unlimited length field as well. If you are using bcrypt then you can use varchar(60) field because bcrypt will generate fixed size 60 character hashes.

1.4 Bruteforce Attack

Bruteforce is an attack where a hacker uses software to try different passwords repetitively until access is granted i.e valid password is found. To prevent a Bruteforce attack, one of the simplest ways is to wait it out approach. When someone is trying to login into your system and tried an invalid password more than 3 times, make them wait for 60 seconds or so before trying again. This way the attacker is going to be slow and it’s gonna take them forever to crack a password.

Another approach to preventing it is to ban the IP that is generating invalid login requests. Your system allows 3 wrong attempts per IP in 24 hours. If someone tries to do brute-forcing then block the IP for 24 hours. This rate-limiting approach is been used by lots of companies to prevent brute-force attacks. If you are using the Express framework, there is a middleware module to enable rate-limiting in incoming requests. Its called express=brute.

You can check the example code below.

Install the dependency.

npm install express-brute --save

Enable it in your route.

const ExpressBrute = require('express-brute');
const store = new ExpressBrute.MemoryStore(); // stores state locally, don't use this in production
const bruteforce = new ExpressBrute(store);
app.post('/auth',
bruteforce.prevent, // error 429 if we hit this route too often
function (req, res, next) {
res.send('Success!');
}
);
//...

The example code is taken from express-brute module documentation.

1.6 Also simply suggestion for developer

Always use “use strict” mode
With use strict, you can use a strict JavaScript “variant”. This allows you to identify some hidden errors and display them.

'use strict'
delete Object.prototype
// input error
var obj = {
a: 1,
a: 2
}
// syntax error

Finding vulnerabilities with Retire.js
The main goal of Retire.js — to help detect vulnerable versions of modules.

Just install it:

npm install -g retire

After that, using the retire command will allow you to detect vulnerabilities in the node_modules directory.

Auditing modules with Node Security Platform CLIENT
nsp is the main command interface for Node Security Platform. It allows you to audit package.json or npm-shrinkwrap.json, comparing it with a list of vulnerable NSP API modules.

npm install nsp --global
# Из директории вашего проекта
nsp check

Reading on the topic: How to protect a web application: basic tips, tools, useful links

2. Server Security

Use appropriate security headers

There are several HTTP security headers that can help you prevent some common attack vectors. The helmet package can help to set those headers:

const express = require("express");
const helmet = require("helmet");
const app = express();app.use(helmet()); // Add various HTTP headers

The top-level helmet function is a wrapper around 14 smaller middlewares. Bellow is a list of HTTP security headers covered by helmet middlewares:

app.use(helmet.hsts()); // default configuration
app.use(
helmet.hsts({
maxAge: 123456,
includeSubDomains: false,
})
); // custom configuration
  • X-Frame-Options: determines if a page can be loaded via a <frame> or an <iframe> element. Allowing the page to be framed may result in Clickjacking attacks.
app.use(helmet.frameguard()); // default behavior (SAMEORIGIN)
  • X-XSS-Protection: stops pages from loading when they detect reflected cross-site scripting (XSS) attacks. This header has been deprecated by modern browsers and its use can introduce additional security issues on the client side. As such, it is recommended to set the header as X-XSS-Protection: 0 in order to disable the XSS Auditor, and not allow it to take the default behavior of the browser handling the response.
app.use(helmet.xssFilter()); // sets "X-XSS-Protection: 0"

For moderns browsers, it is recommended to implement a strong Content-Security-Policy policy, as detailed in the next section.

app.use(
helmet.contentSecurityPolicy({
// the following directives will be merged into the default helmet CSP policy
directives: {
defaultSrc: ["'self'"], // default value for all directives that are absent
scriptSrc: ["'self'"], // helps prevent XSS attacks
frameAncestors: ["'none'"], // helps prevent Clickjacking attacks
imgSrc: ["'self'", "'http://imgexample.com'"],
styleSrc: ["'none'"]
}
})
);

As this middleware performs very little validation, it is recommended to rely on CSP checkers like CSP Evaluator instead.

  • X-Content-Type-Options: Even if the server sets a valid Content-Type header in the response, browsers may try to sniff the MIME type of the requested resource. This header is a way to stop this behavior and tell the browser not to change MIME types specified in Content-Type header. It can be configured in the following way:
app.use(helmet.noSniff());
  • Cache-Control and Pragma: Cache-Control header can be used to prevent browsers from caching the given responses. This should be done for pages that contain sensitive information about either the user or the application. However, disabling caching for pages that do not contain sensitive information may seriously affect the performance of the application. Therefore, caching should only be disabled for pages that return sensitive information. Appropriate caching controls and headers can be set easily using the nocache package:
const nocache = require("nocache");
app.use(nocache());

The above code sets Cache-Control, Surrogate-Control, Pragma and Expires headers accordingly.

  • X-Download-Options: This header prevents Internet Explorer from executing downloaded files in the site’s context. This is achieved with noopen directive. You can do so with the following piece of code:
app.use(helmet.ieNoOpen());
  • Expect-CT: Certificate Transparency is a new mechanism developed to fix some structural problems regarding current SSL infrastructure. Expect-CT header may enforce certificate transparency requirements. It can be implemented in your application as follows:
const expectCt = require('expect-ct');
app.use(expectCt({ maxAge: 123 }));
app.use(expectCt({ enforce: true, maxAge: 123 }));
app.use(expectCt({ enforce: true, maxAge: 123, reportUri: 'http://example.com'}));
  • X-Powered-By: X-Powered-By header is used to inform what technology is used in the server side. This is an unnecessary header causing information leakage, so it should be removed from your application. To do so, you can use the hidePoweredBy as follows:
app.use(helmet.hidePoweredBy());

Also, you can lie about the technologies used with this header. For example, even if your application does not use PHP, you can set X-Powered-By header to seem so.

app.use(helmet.hidePoweredBy({ setTo: 'PHP 4.2.0' }));

Also simple tips for engeneer

Keep your packages up-to-date

Security of your application depends directly on how secure the third-party packages you use in your application are. Therefore, it is important to keep your packages up-to-date. It should be noted that Using Components with Known Vulnerabilities is still in the OWASP Top 10. You can use OWASP Dependency-Check to see if any of the packages used in the project has a known vulnerability. Also, you can use Retire.js to check JavaScript libraries with known vulnerabilities.

Starting with version 6, npm introduced audit, which will warn about vulnerable packages:

npm audit

npm also introduced a simple way to upgrade the affected packages:

npm audit fix

There are several other tools you can use to check your dependencies. A more comprehensive list can be found in Vulnerable Dependency Management CS.

Run security linters

When developing code, keeping all security tips in mind can be really difficult. Also, keeping all team members obey these rules is nearly impossible. This is why there are Static Analysis Security Testing (SAST) tools. These tools do not execute your code, but they simply look for patterns that can contain security risks. As JavaScript is a dynamic and loosely-typed language, linting tools are really essential in the software development life cycle. The linting rules should be reviewed periodically and the findings should be audited. Another advantage of these tools is the feature that you can add custom rules for patterns that you may see dangerous. ESLint and JSHint are commonly used SAST tools for JavaScript linting.

--

--

Ivan Piskunov

DevSecOps expert, Security Evangelist, Researcher, Speaker, Book’s author