Node.js Security 101: Protecting Your Application from Common Web Attacks

January 23, 2023 · 12 min read

blog/node-js-security-101-common-web-attacks

Introduction

In this blog post, I’ll explain common web threats and how to defend against them using Node.js. As web applications continue to play a vital role in our daily lives, it’s more important than ever to ensure that they are secure and protected from potential attacks. This post will dive into some of the most common web threats, such as SQL injection, XSS, and CSRF, and provide practical solutions and code snippets on how to defend against them using Node.js.

Some of the most common potential attacks include:

  1. SQL Injection: This attack occurs when an attacker can insert malicious SQL code into a web application’s query, allowing them to access or manipulate sensitive data in the database.

  2. Cross-Site Scripting (XSS): XSS attacks involve injecting malicious scripts into a web page viewed by other users, which can steal sensitive information such as login credentials and session cookies.

  3. Cross-Site Request Forgery (CSRF): this type of web attack occurs when a malicious website causes a user’s browser to request a different website for which the user is currently authenticated.

  4. Insecure Cryptographic Storage: Many web applications store sensitive information in a database, such as user passwords and credit card numbers. It’s crucial to use robust encryption algorithms and to properly secure the encryption keys, to store this data encrypted.

  5. Insecure Authentication and Authorization: Applications that use weak or easily guessed passwords or fail to properly check user roles and permissions can be vulnerable to unauthorized access and privilege escalation attacks. Use robust authentication mechanisms to prevent this attack and implement proper access controls.

  6. Insecure Communication: Applications that communicate over insecure channels, such as plain HTTP, are vulnerable to eavesdropping and man-in-the-middle attacks. Avoid this attack by using secure communication mechanisms such as HTTPS.

SQL injection

Here are some best practices for protecting against SQL injection attacks in a Node.js application:

Use prepared statements and parameterized queries: this can help protect against SQL injection attacks by ensuring that user input is escaped correctly and sanitized before being used in a SQL query.

Here is an example of how to use a parameterized query in Node.js using the mysql2 library:

const mysql = require('mysql2');
const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: 'password',
  database: 'database'
});

const userId = "some user input";
const sql = 'SELECT * FROM users WHERE id = ?';

connection.execute(sql, [userId], (err, results) => {
  if (err) throw err;
  console.log(results);
});

In this example, the ? is a placeholder for the user input (userId). The library mysql2 will handle the proper escaping and sanitizing before executing the query.

A SQL injection attack can occur if the user input isn’t properly sanitized before being included in the query. For example, if the value of the "id" parameter is obtained from an untrusted source, an attacker could manipulate the input to include additional SQL commands. For example, if the attacker were to enter the value "1 OR 1=1" for the "id" parameter, the resulting query would be:

SELECT * FROM users WHERE id = 1 OR 1=1

This query would return all rows from the "users" table because the condition "1=1" is always true. An attacker could also use this technique to drop tables, steal data, or execute other malicious actions by adding additional SQL commands to the user input.

Also, Validate user input before using it in a query. This can help prevent malicious data from being passed to the SQL query and can help ensure that the data being passed is of the correct type and format.

Here is an example of how to validate user input in Node.js using the validator library:

const validator = require('validator');

const userId = "some user input";
if (!validator.isInt(userId)) {
    throw new Error('Invalid user id');
}

In this example, the validator.isInt() function is used to check if the userId is a valid integer; if it’s not, the function will return false, and an error will be thrown.

Using an Object-Relational Mapping (ORM) library can help simplify the process of interacting with a database and can also help provide additional protection against SQL injection attacks. ORM libraries like Sequelize, TypeORM, and Mongoose provide built-in protection against SQL injection attacks by handling the escaping and sanitizing of the user input.

Limit access to the database by using a least privileged user account with only the necessary permissions. This can help reduce the potential impact of a successful SQL injection attack.

Regularly update and patch your dependencies: Many libraries have released security updates that fix known vulnerabilities, so it’s important to stay up-to-date with the latest versions.

These best practices can help protect against SQL injection attacks in a web application. Still, it’s important to note that no single solution can completely eliminate the risk of a SQL injection attack. It’s essential to use a combination of different techniques and to stay up-to-date with the latest security best practices.

Cross-Site Scripting (XSS)

XSS attacks occur when an attacker can inject malicious code into a web page viewed by other users, typically in the form of a script. This can be done by exploiting a vulnerability in the web application, such as a lack of proper input validation. Once the script is executed, it can steal user data, such as cookies and session tokens, or perform other malicious actions on the user’s behalf.

There are two types of XSS attacks: stored and reflected.

Stored XSS occurs when the attacker can inject malicious code into a web page that is stored on the server, such as in a database. The malicious code is executed every time the page is viewed.

Reflected XSS occurs when the attacker can inject code into a web page generated on the fly, such as through a search form. In this scenario, the code is executed when the page is viewed by the user that submitted the form.

The risks of XSS attacks are severe, as they can lead to the theft of sensitive information, such as login credentials and personal data. They can also be used to spread malware and perform other malicious actions on the user’s behalf.

Protect your application from XSS attacks

Validate and sanitize user input on both the client and server side. This can include using functions to escape special characters, using a Content Security Policy (CSP) to restrict the types of scripts that can be executed.

On the server side, you can use the package “xss-clean” to sanitize the user input before storing it in the database.

Here is an example of how to use it:

const express = require('express');
const xss = require('xss-clean');

const app = express();

// Enable xss-clean middleware
app.use(xss());

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post('/submit-form', (req, res) => {
    // user input is automatically sanitized by the xss-clean middleware
    const { userName, userComment } = req.body;

    // save the sanitized data to the database
    saveToDatabase(userName, userComment);
    
    res.send('Data saved successfully');
});

function saveToDatabase(name, comment) {
    // database code here
}

This will automatically sanitize all user input for your application. You can also use it on specific routes or middlewares by calling it like a function:

const sanitizedInput = xss(userInput);

On the client side, you can use the package “DOMPurify” to sanitize the user input before rendering it on the page. Here is an example of how to use it:

const DOMPurify = require('dompurify');
const sanitizedHtml = DOMPurify.sanitize(userInput);

To avoid XSS attacks when using React, use a library such as react-escape or react-no-danger to escape potentially dangerous user input when displayed in the browser. Also use React’s dangerouslySetInnerHTML prop with caution.

Here is an example of using the react-escape library to prevent cross-site scripting (XSS) attacks in a React application:

import ReactEscape from 'react-escape';

function MyComponent() {
  const userInput = "Hello <script>alert('XSS')</script>";
  return (
    <div>
      <h1>Welcome</h1>
      <p>{ReactEscape.escape(userInput)}</p>
    </div>
  );
} 

In this example, the user input is sanitized by calling the function ReactEscape.escape(), which will escape any potentially dangerous characters, such as < and >, so it’s rendered as plain text instead of HTML or JavaScript. This helps to prevent XSS attacks by ensuring that any malicious code entered by a user is not executed by the browser.

Another measure to protect against XSS is to use the “HTTP Only” attribute on cookies; this will make them inaccessible to javascript and prevent them from being stolen by XSS attacks.

Cross-Site Request Forgery (CSRF)

Cross-Site Request Forgery (CSRF) is a web attack that occurs when a malicious website, email, or other application causes a user’s web browser to perform an unwanted action on a different website for which the user is currently authenticated. This can happen if the user is logged into a website, and a malicious website can trick their browser into making a request to the logged-in website on their behalf.

csrf-attack

One way to prevent CSRF attacks in a web application is to use a CSRF token. A CSRF token is a unique, securely generated value that is associated with the user’s session and included in the HTML form sent to the browser. When the form is submitted, the token is sent back to the server along with the form data, and the server can then check that the token received matches the one associated with the user’s session.

Here is an example of how to implement CSRF protection in a Node.js application using the express-csurf middleware:

const csurf = require('csurf');
const csrfMiddleware = csurf({ cookie: true });

app.use(csrfMiddleware);

app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

app.post('/process', (req, res) => {
  if (!req.body.csrfToken || req.body.csrfToken !== req.csrfToken()) {
    // Handle error, the CSRF token is invalid
  } else {
    // The CSRF token is valid, process the form data
  }
});

In the above code snippet, the express-csurf middleware is added to the app, and the csrfToken value is passed to the form template and added as a hidden input field. The csrfToken is then checked on form submission to ensure it is the same as the one stored on the server.

Remember that CSRF tokens should be rotated and only stored on the server side. Also, make sure the tokens are not leaked out.

Encrypt sensitive data for storage

Using a secure password hashing algorithm for user passwords is essential to keep your web application safe. Also, use symmetrical encryption when storing sensitive information. Two examples of secure hashing algorithms are bcrypt and scrypt. These algorithms are considered safe to use and can help protect users’ sensitive data from unauthorised access. It’s also essential to use a regularly updated and secure method to store the encryption key.

Here is an example of how to hash a password using the bcrypt library in Node.js:

const bcrypt = require('bcrypt');

const plainTextPassword = "userpassword";
const saltRounds = 10;

bcrypt.hash(plainTextPassword, saltRounds, (err, hash) => {
    if (err) throw err;
    //store the hash in the database
});

Use a secure encryption algorithm to store sensitive data, such as credit card numbers or personal information. I recommend an encryption algorithm such as AES.

Here is an example of how to encrypt data using the crypto library in Node.js:

const crypto = require('crypto');
const algorithm = 'aes-256-cbc';
const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);

function encrypt(data) {
  let cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
  let encrypted = cipher.update(data);
  encrypted = Buffer.concat([encrypted, cipher.final()]);
  return { iv: iv.toString('hex'), encryptedData: encrypted.toString('hex') };
}

Here’s an example of a decrypt function that can be used to decrypt data that was encrypted using the encrypt function:

function decrypt(data) {
  let iv = Buffer.from(data.iv, 'hex');
  let encryptedText = Buffer.from(data.encryptedData, 'hex');
  let decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv);
  let decrypted = decipher.update(encryptedText);
  decrypted = Buffer.concat([decrypted, decipher.final()]);
  return decrypted.toString();
}

This function takes in an object with the properties iv and encryptedData, both of which are expected to be strings in hexadecimal format. It first converts these strings into Buffer objects, then is used to create a decipher object. It then calls the update() and final() methods on the decipher object to decrypt the data and concatenates the results. Finally, it returns the decrypted data as a string.

Be aware that this example uses a hardcoded key, it is not recommended to use hardcoded keys in production, and it’s important to securely store the key. The key used in the algorithm should be kept secret on your server to ensure that only authorized parties can decrypt the data stored in the database. In the example, the key is generated using crypto.randomBytes(32) method, which creates a 32-byte (256-bit) key. This key is used to encrypt and decrypt the data. It should be stored in a secure location on your server, such as in a secure configuration file or as an environment variable that is not committed to version control.

Use proper key management to secure encryption keys properly. Previously I mentioned that you can use a secure configuration file or environment variables but also consider using a key management service such as AWS Key Management Service (KMS) or Hashicorp Vault.

Do not store sensitive data in plain text: Never store sensitive data, such as credit card numbers or personal information, in plaintext in the database or in the codebase.

Keep your dependencies updated by regularly updating and patching your dependencies, as many libraries have released security updates that fix known vulnerabilities.

Conclusion

In conclusion, keeping your web application safe is super important in today’s world. By knowing about common security issues like SQL injection, XSS, and CSRF and taking steps to prevent them, you can significantly decrease the chances of an attack. In this post, I went over these issues and how to protect against them using Node.js. I explained about using safe ways to store data and communicate, validating user input, and staying up to date with the latest security best practices. It’s also important to check for potential vulnerabilities and test your application regularly. By taking these steps, you’ll be able to keep your users’ data safe and your business secure.

Pedro Alonso

Software developer and consultant. I help companies build great products. I've worked with all kinds of companies. Contact me by email.

Get my new content delivered straight to your inbox. No spam, ever.

© 2023 Pedro Alonso