node common vulnerabilities best practices

Another article on Node.js security? But in this one, we focus on the most common vulnerabilities encountered during penetration testing.

Node related vulnerabilities have consequences for your entire web application. It is therefore essential to detect and correct them.  Some of these flaws are not specific to Node and also exist in other languages and frameworks. This is why we have focused on providing general best practices and specific tools for Node.js.

Let’s dive in.

Protecting from Cross Site Scripting vulnerabilities

Cross-Site-Scripting (XSS) vulnerabilities are a typical example of a very well-known, but still very often found vulnerability during a penetration test of a web application.

A Cross-Site-Scripting flaw allows content to be injected into a page. To protect against this, it is essential to encode user data. You can escape the data by converting it into HTML, Unicode… Modules exist for this, like escape-html or node-esapi.

It is also interesting to flag cookies with httpOnly so that the JS client cannot access them.

response.setHeader('Set-Cookie', 'cookieName=cookieValue; HttpOnly');

In addition, CSP (Content Security Policy) script rules should be defined. They allow you to authorise content according to a list you decide and therefore prohibit the loading of specific types of content.

If there is ever an XSS, using CSPs allows you to restrict what an attacker can do.

You can define whether external scripts can be loaded, whether inline scripts can be executed, whether resources can be loaded from the same origin as the main page, etc.

response.setHeader("Content-Security-Policy", "script-src 'self' https://apis.google.com");

Preventing SQL injections

Like XSS vulnerabilities, SQLi vulnerabilities are well known (they have been discussed for more than 20 years -first public article in 1998-). We still frequently find them during pentests.

They involve adding an unintended query to an SQL query to interact with the database. An SQLi flaw allows data to be stolen or modified, or even remote code to be executed. You can learn more about this flaw in our article on SQL injections.

To protect against this, there are several options.

The main defensive principle is to use prepared and pre-compiled queries, also known as parametrised queries.  This means defining upstream a query with parameters instead of constant values in the query.

Several ORMs and query-builders allow you to make prepared queries.

Example with Knex:

knex('users').where({
  first_name: 'Test',
  last_name:  'User'
}).select('id')

Currently, the recommendation is to use an ORM (Object Relational Mapping), i.e. a set of libraries that interact with the data via objects. With an ORM, there is no need to compose SQL queries directly. This avoids injections when it is well used and kept up to date (don’t forget to patch it if a flaw is discovered).

Sequelize is the main ORM. There are also query-builders, which are “simpler” versions of ORMs.  One of the most widely used is Knex.

However, keep in mind that ORM does not guarantee complete protection against injections. It is essential to keep validating user data.

Defending against CSRF vulnerabilities

A CSRF vulnerability occurs when an attacker is able to force end-users to perform unwanted actions without them realising it. CSRF attacks target data modification requests and are carried out on authenticated users.

To protect against them, the principle is to secure GET requests and ensure that the requests come from the web application.

GET requests should only fetch resources, but never modify anything on the server.

You can take several measures, such as:

  • Use authentication tokens (e.g. JWT tokens)
  • Use an anti-CSRF token. There is for example the csurf library.
  • If the application uses cookies, put the SameSite instruction on the cookies
  • Use HTTP security headers, like
    • strict-transport-security – to use only https connections
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  • x-frame-options – this header allows pages to be included in iframes or not. Attackers can create iframes with your site to trick users into clickjacking attacks.  It is generally recommended to set the header to Sameorigin (iframe are only allowed if the iframe is on a site with the same domain)
  • x-content-type-options – to ensure that the browser takes the content-type directive into account and that changing MIME types is not possible. The recommended directive is “X-Content-Type-Options: nosniff”.
  • referrer-policy – this defines what levels of information the browser sends in the header. You can see the different possible directives on the W3C website.
  • permissions-policy – which defines which browser features and APIs can be used by the browser. You can juggle between ‘self’, src or () to disallow the functionality altogether.

You can check the headers with for example the website https://securityheaders.com/.

Linked to CSRF, it is necessary to secure CORS. Indeed, if they are badly configured, it will create a CSRF flaw.

CORS (Cross-Origin Resource Sharing) consists of adding http headers to access resources from a server located on another origin than the current site.

In some cases, there is a real need to allow this functionality. It is then recommended to define the domains from which you expect to get requests (very often, it is only your domain). Do not allow:

res.setHeader('Access-Control-Allow-Origin', '*'

Preventing information leaks

You need to monitor several places where information leakage occurs regularly. This can be when there are error messages (during authentication, on the infrastructure…) or in configuration files.

For the first case, the protection is to continue logging errors, but simply not to show them to users.

In the second case, it is advisable not to write secrets in the configuration files or in the source code. It is worth checking from time to time with queries or code review that secrets are not accidentally exposed.

Countering brute force attacks

Brute force attacks on authentication pages are a common practice when trying to obtain a password or key. This consists of testing all possibilities one by one.

To protect against this, it is necessary to limit the number of requests per IP per minute by setting up a rate limiting. Packages exist for node such as rate-limiter, express-brute… Depending on your needs, you can combine several modules.

It is also recommended to install a captcha, which allows you to ensure that the request comes from a legitimate user. You can use Recaptcha from Google or one of the node.js modules.

Avoiding access control errors

Node does not provide a native solution to manage access control. But fortunately, many modules exist and allow to create roles and assign users to these roles, to manage hierarchical inheritances, such as acl, passport

The control of rights is ideally set up as high as possible in the organisation.  In a Node.js environment, a middleware is particularly suited for this.

Controlling installed packages

Node works with an extremely extensive module system. Thousands of packages are available. However, this advantage can be a source of flaws. Some packages are no longer or poorly maintained. Some packages are no longer maintained or are poorly maintained. Others include sub-packages, which builds a system of sub-dependencies where it is difficult to control and examine all packages.

To protect against this, you should combinate:

  • Tools that check modules, such as YARN audit or NPM audit package managers. They audit packages and can find certain vulnerabilities.
  • And having a good package maintenance policy.

Protecting against race conditions vulnerabilities

Node is asynchronous. This allows it to run optimally, but problems can arise when instructions are executed before others, creating race condition vulnerabilities.

This flaw is quite hard to detect. It requires investigation after a scan that has behaved strangely for example.

It occurs when a system uses a shared resource, and a time interval exists between the time a control request is made on the resource and the time the request is executed.

To protect yourself against it, you should be very careful when making an asynchronous call and make sure that, during a sensitive operation, all security checks are completed before the operation itself occurs, especially via callbacks or promises.

For example, for a money transfer that check if an account has enough money before making the transfer:

verifyAccountBalance() ; // Throw an error if account balance is not enough
transferMoney() ;

If the verifyAccountBalance function is asynchronous, there is a risk that transferMoney will be called before it has finished checking whether the operation is possible or not.

It is therefore better to use, for example, a callback to ensure the correct execution order:

verifyAccountBalance(function(error) {
	if (error) {
		// Handle error (probably not enough money !)
		return;
}
	transferMoney() ;
});

CONCLUSION

As we have just seen, securing a Node application requires checking many elements: the front-end logic, the server side, data storage, data transport and more.

Many of the vulnerabilities share the same causes and by studying the most common ones, you will manage to prevent them and to protect your application.