Node.js is a JavaScript runtime environment, meaning it has everything you need to execute a program written in JavaScript. Though quite secure by itself, you should still think of additional ways to secure your Node.js apps.
Vulnerabilities are increasing as attackers find new, clever ways to break in to your apps. It’s important to protect your customers’ sensitive data.
In this article, we will look at some popular attacks and threats to Node.js apps followed by steps you can take before the development process to ensure the security of your Node.js app.
Here’s what we will be covering today:
Consider this course your guide for securing Node.js apps. You’ll learn everything from sanitizing user inputs, encryption, hashing algorithms, and beyond.
It’s important to know how hackers/attackers try to breach your Node.js app before you can start defending it. This section will briefly cover some of the popular attacks and threats, and explain how they work.
Cross-site scripting lets hackers inject malicious client-side scripts into a viewed page, leading to data leaks. They can also redirect users to malicious websites. XSS attacks occur on input forms when they are not properly encoded or validated.
CSRF attacks target changes in an application’s state requests leading to end users executing unnecessary actions on authenticated web applications. Hackers can send users links through chat or email, asking them to change email addresses or transfer funds.
Brute force attacks involve an attacker relying on automated software that can guess data over time. Brute force attacks can crack encrypted passwords and PINs. Default cookies session name Your actions on a website are stored as cookies. Using default cookie names is a threat, as hackers can easily identify them.
One of the largest open-source package ecosystems, npm is Node.js’s default package manager. Though npm helps with app performance and developer productivity, you need to keep in mind the dependencies and security risks involved.
DoS attacks either slow down your service or crash it completely. Attackers accomplish this by constantly creating traffic and sending requests.
A common hacking technique that could destroy your database, SQL injections occur when you ask users for input, e.g., their id or username, and they enter an SQL statement instead. Attackers can bypass authentication, retrieve, add, or modify your database.
SQL injections are fairly common strategies for attackers, so it’s important to learn how to defend against them. Let’s start with an example.
textuserID = getRequestString("userID");
textSQL = "SELECT * FROM Users WHERE userID = " + textuserID;
In this example, we have a variable (textuserID
) that is fetched from the user input. The next line of code selects a user based on their id.
Let’s look at some of the ways things can go wrong.
If your user enters something like 100 OR 1=1 for their userID, the SQL statement looks like this:
SELECT * FROM Users WHERE userID = 100 OR 1=1;
Since 1=1
is always true, the above statement will return all the rows of the Users table. This can be pretty dangerous if your table contains information like usernames and passwords.
Let’s look at another example:
userName = getRequestString("username");
userPass = getRequestString("userpwd");
sql = 'SELECT * FROM Users WHERE Name ="' + userName + '" AND Pass ="' + userPass + '"'
The above code gets the username and password of a user and selects data based on that. If your user enters:
The SQL statement becomes:
SELECT * FROM Users WHERE Name ="" or ""="" AND Pass ="" or ""=""
This will return all the rows from the Users table because OR “”=””
is always true.
Enjoying the article? Scroll down to sign up for our free, bi-monthly newsletter.
Batched SQL statements are basically multiple SQL statements separated by semicolons. Let’s look at how this works.
SELECT * FROM Users; DROP TABLE Orders
The above example returns all the rows from the Users table and then deletes the Orders table. If your user enters something like 100; DROP TABLE Orders;
for their userID, the SQL statement becomes:
SELECT * FROM Users WHERE userID = 105; DROP TABLE Orders;
If your user enters
100 OR 1=1
as their userID, the parameterized statement will look for a user ID=100 OR 1=1
.
Apply input validations: Input validations add an extra layer of protection. You can compare an input to a list of allowed options when writing your validation logic. Input validation also protects you from XSS attacks, DoS, and other injection attacks.
Use database accounts with least privilege: Using database accounts with minimum required privileges protects your app from SQL injections. Don’t use admin level rights database accounts in your Node.js app. Create different users with read-write and read-only permissions. Lastly, use SQL views to limit access to specific table columns or joins
Sanitize input: You need to ensure you get rid of suspicious-looking input. You can do that by checking that fields like email addresses match a regular expression, alphanumeric or numeric fields do not contain special characters, and strip any newline or white space characters.
HTTP headers allow both the server and client to send and receive data about the connection to be established, the resource being requested and the returned resource. You might be ignoring HTTP headers because the users don’t see them. That isn’t the best idea, as they can leak sensitive information.
Express apps leak information through the X-Powered-By header. This header lets the browser know the server’s version and vendor used. Hackers can then cross-reference this with publicly disclosed vulnerabilities allowing your app to be targeted easily.
That’s where Helmet.js comes in, a Node.js module for securing HTTP headers. It has 12 Node modules that interfere with Express, and each module provides configuration options for securing different HTTP headers.
Learn how to secure Node.js apps without scrubbing through videos or documentation. Educative’s text-based courses are easy to skim and feature live coding environments, making learning quick and efficient.
Password encryption allows you to convert your passwords into an unreadable message using an encryption key. The encryption key is a mathematical value that both you and the recipient know.
The recipient uses the encryption key to turn the random text back to a readable message. Encryption is a two-way function. So, if you encrypt something, you need to decrypt it later.
Encryption has the following two types:
Encryption helps protect data from unauthorized users. It also helps with data breaches. If your data is encrypted properly, it will remain secure.
Hashing is one way-encryption. It’s different from encryption because it doesn’t have a decryption key. Here’s how it works, suppose your password is Pa55word
. Once passed through a hashing algorithm, it will look something like dhkqhuhdhudhuh
.
Every time you enter ``Pa55word, it is hashed and if it matches
dhkqhuhdhudhuh`, you can sign in.
You can use the library bcryptjs to secure passwords in Node.js. bcryptjs takes the password and salt, which is the number of times it should execute the hashing algorithm.
The example below uses different function calls for generating the hash and salt.
bcrypt.genSalt(saltRounds, function(err, salt) {
bcrypt.hash(myPlaintextPassword, salt, function(err, hash) {
// Store hashed password in database
});
});
Or you could auto generate a hash and salt using:
bcrypt.hash(PlainTextPassword, saltRounds, function(err, hash) {
// Store hashed password in database
});
You can also compare your password to the hashed password stored in the database using the compare method. If the passwords match, compare returns true, if not, false.
bcrypt.compare(PlainTextPassword, hash, function(err, result) {
// result == true
});
A safe default means you define your variables and properties with a default false, empty, or null state. So, when determining your logical flow, your default is a failure.
For example, if you are checking if a password is correct, you go with the positive application flow, if it isn’t then it should go with the non-positive result.
Here is a basic form validation example:
app.post('/signup', function() {
//the default logic is failure
var message = 'Invalid Form Data';
//check for a valid form
if (form.validate('signup') === true) {
//process the form
var message = 'Form Valid';
}
});
Node.js is a dynamically typed language. Dynamic typed languages check the variable type as the code runs, this means that the variable’s type will change over its lifetime.
Let’s look at this example:
var isActive = 'false';
if (isActive) {
console.log("we shouldn't be here, yet we are");
}
else {
console.log("we should be here");
}
You’re trying to figure out if isActive
is true or false, but it’s being returned as a string.Though dynamic typing may make development easier for you, you are more likely to run into errors related to variable type, which may at times be difficult to locate.
Be sure to not completely rely on dynamic typing!
A race condition occurs when you have multiple processes accessing a shared resource at the same time and one or more of them want to modify it. This results in an unexpected logical flow.
Here’s an example. Suppose you have a variable, a=5
, you have two processes X
and Y
. X
wants to add 6 to a, and Y
wants to add 3 only if a is less than 6. Depending on which process executes first, you will have very different results.
Due to its asynchronous nature, race conditions are uncommon in JavaScript. But if they do appear, they can be very difficult to debug, so it’s best to handle them before they happen. Here’s how you can prevent race conditions.
Callbacks allow you to pass one function as a parameter to another function. The function is then invoked when the process completes. Let’s look at this example. Below, we won’t be using any callbacks:
function generateCsrf(req, res, next) {//safe default!req.session.csrfToken = null;var token = null//generate a new tokenvar crypto = require('crypto');crypto.randomBytes(48, function(err, buffer) {//this will run after the below//code has been runtoken = buffer.toString('hex');});//save the token in the session and proceedreq.session.csrfToken = token;//let's try to print the token for this democonsole.log(token)next();};
The token is null by the time the function next()
is called as the token didn’t finish generating. To ensure that this doesn’t happen, we wait for token generation to complete first.
function generateCsrf(req, res, next) {//safe default!req.session.csrfToken = null;//generate a new tokenvar crypto = require('crypto');crypto.randomBytes(48, function(err, buffer) {var token = buffer.toString('hex');//save the token in the session and proceedreq.session.csrfToken = token;//let's try to print the token for this democonsole.log(token)next();});};
This concept is also important with database writes. You need transactions to only apply certain changes if all your statements are successful.
Congratulations on making it to the end. I hope you are now familiar with some of the practices for securing your Node.js apps. There is still a lot to learn about securing apps, especially considering all the different requirements out there.
Here’s what we recommend for next learning steps:
To get started with these concepts and practice what we learned today, check out Educative’s course Securing Node.js Apps. This course will teach you all you need to know from sanitizing input to protecting against XSS and CSRF with a hands-on coding environment. By the end, you will know how to secure a Node.js application.
Happy learning!
Free Resources