Security Best Practices for Your Node.js Application
Omonigho Kenneth Jimmy
Posted on July 17, 2024
The widespread adoption of Node.js continues to grow, making it a prime target for XSS, DoS, and brute force attacks. Therefore, protecting your Node application from possible vulnerabilities and threats is crucial.
In this guide, we'll uncover common security threats and explore best practices for preventing them. You don't have to be a cybersecurity expert to implement fundamental security measures for your Node.js application.
So, are you ready? Let's go! 🚀
Understanding Node.js Security Risks and Implementing Security Best Practices
Let's explore the common security risks associated with Node.js applications and practical ways to mitigate them.
Threat: Injection Attacks (SQL, NoSQL)
Sensitive data lives in your database. If an attacker inserts malicious queries or commands into input fields and finds their way in, they can gain unauthorized access to users' passwords, restricted data, or worse. This type of attack is known as SQL or NoSQL injection.
For instance, suppose your application exposes an endpoint that accepts a movie category from a user input (through a query param):
https://api.my-site.com/v1/movies?category=action
This prompts the application to execute the following SQL query and fetch all released action movies from the database:
SELECT * FROM movies WHERE category = 'action' AND isReleased = true;
An attacker can construct an attack by injecting malicious SQL into the input like so:
https://api.my-site.com/v1/movies?category=action'--
This results in the following SQL query:
SELECT * FROM movies WHERE category = 'action'--' AND isReleased = true;
Because --
is a comment indicator in SQL, the rest of the statement after it, AND isReleased = true
, will be ignored. As a result, all movies are displayed, including those not yet released.
Mitigate Injection Attacks
Let's now see what we can do to mitigate such attacks.
Use of Parameterized Queries
Use parameterized queries instead of directly concatenating user input into the SQL query string. These techniques, also known as prepared statements, separate SQL logic from data, ensuring that user input is treated as a value rather than executable SQL code.
For instance, based on the movie app example above, don't process the user input like this:
import * as mysql from 'mysql';
const connection = mysql.createConnection({
// db credentials
});
connection.connect();
const userInput = 'action'--';
const query = `SELECT * FROM movies WHERE category = '${input}' AND isReleased = true`;
connection.query(query, (error, results, fields) => {
// handle results
});
Do this instead:
import * as mysql from 'mysql';
const connection = mysql.createConnection({
// db credentials
});
connection.connect();
const userInput = 'action'--';
const query = 'SELECT * FROM movies WHERE category = ? AND isReleased = true';
connection.query(query, [input], (error, results, fields) => {
// handle results
});
The second example uses a prepared statement, where the ?
placeholder is the input value rather than a direct concatenation of the user input into the SQL query string (as shown in the first example). The user input is, therefore, passed separately as parameters to the prepared statement, preventing it from being interpreted as SQL code.
It's noteworthy that while prepared statements can handle string
and number
values effectively, they cannot and should not be used to parameterize table names, column names, SQL keywords, or other structural components of an SQL statement. If we have to make our query more dynamic, adding operators and identifiers as well as values, we should use other strategies like whitelisting. This implies that every dynamic parameter should be manually set in your script and selected from that predefined set.
For instance, suppose you want to add ORDER BY
dynamically into your SQL query. You can whitelist a set of allowed parameters and match the user input against this whitelist before constructing the query like so:
const orders = ["name", "price", "quantity"]; // whitelisted columns; you may use a hash map
const userInput = " UNION SELECT username, password FROM users--"; // malicious input
if (!orders.includes(userInput)) throw new Error("Invalid input!");
const query = `SELECT * FROM movies ORDER BY ${userInput}`;
This technique ensures that the query only utilizes valid and anticipated identifiers.
Sanitize User Input
Sanitizing input against database injection attacks involves removing or escaping keywords and characters that could be interpreted as part of an SQL/NoSQL query. Unsanitized user input is risky because, as demonstrated in the movie app example, attackers can leverage it to send malicious queries through your application to your database and steal important data.
There are several known npm packages available for sanitizing SQL/NoSQL queries:
-
sqlstring provides utility functions for escaping and formatting MySQL. For example, it provides methods like
escape()
, which replaces special characters within the given input string with escape characters to ensure they are treated as literal values rather than parts of the SQL syntax. - pg-format formats SQL queries in PostgreSQL applications and provides functionality similar to that of sqlstring.
-
express-mongo-sanitize is popularly used in MongoDB applications to prevent Operator Injection. It works by analyzing request data and removing any MongoDB operators or characters such as
$gt
,$lt
,$eq
,$ne
,$regex
,$where
, and others that could potentially be used to execute malicious queries.
Use of ORMs/ODMs
If your application doesn't necessitate raw SQL/NoSQL, opt for Object-Relational Mappers (ORMs) like Sequelize or Object-Document Mappers (ODMs) like Mongoose for database queries. They feature built-in protection against injection attacks, such as parameterized queries, automatic escaping, and schema validation, and adhere to some security best practices.
Threat: Cross-site Scripting (XSS)
Using an input field, an attacker can store a malicious script (typically written in JavaScript) in your database through your Node.js app. The script is then served to multiple users whenever they access the affected web page or resource, and then executed in the victim's browser. This allows the attacker to steal sensitive information, manipulate web page content, or perform unauthorized actions on behalf of the user. This type of attack is known as cross-site scripting.
How to Prevent Cross-site Scripting
We'll now turn to how we can avoid cross-site scripting.
Validate User Input
Validate all user-supplied input to ensure that it conforms to expected formats and does not contain malicious code. Use libraries like joi or class-validator for data validation.
For example:
- If a value is expected to be numeric, validate that it's numeric, and likewise for other data types.
- If a user submits a URL that you intend to include in responses, validate that it starts with a secure protocol, like HTTP or HTTPS.
Output Encoding
Encode output data before user-controllable data is written to a page to prevent it from being interpreted as HTML or JavaScript. You can use tools like xss for this purpose.
In an HTML context, you should use HTML entities like so:
< converts to < > converts to >
In a JavaScript string context, non-alphanumeric values should be Unicode-escaped:
< converts to \u003c
> converts to \u003e
Use of HTTPOnly
Cookies
If using cookies, set the HTTPOnly
flag to prevent access from client-side JavaScript. You can also use the secure
flag to ensure that cookies are only sent over HTTPS connections.
Setting HTTP Headers
Setting special headers in your Node.js application is essential for controlling access and ensuring data integrity, thereby enhancing protection against XSS attacks.
- Use libraries such as helmet to set special HTTP headers, like
X-XSS-Protection
for mitigating XSS attacks. - Set the
Content-Disposition
header to an attachment for file downloads to prevent browsers from executing downloaded files as scripts or HTML content.
Threat: Brute Force
What would you do if you wanted to unlock a phone but didn't know the password? You might try multiple combinations, but how many combinations can you make in a day if that would take hundreds, thousands, or even millions of guesses?
What does it take to pull this off in minutes? Brute force. This type of attack is a forceful and repetitive trial-and-error method used to guess all possible combinations of a password, encryption key, or any other login information to gain unauthorized access.
How to Mitigate Brute Force
Here's how we can help prevent brute-force attacks.
Rate Limiting
Use middleware or third-party packages such as express-rate-limit to enforce rate limits and restrict the number of API calls from a single IP address within a specific time frame.
Application of Maximum Login Attempts
Implement maximum login attempts and enforce account lockout when the threshold is exceeded.
Application of Multi-Factor Authentication (MFA)
Implement Multi-Factor Authentication (MFA). Require users to provide a second form of authentication, such as a one-time passcode sent via SMS or email, in addition to their password.
Threat: Cross-Site Request Forgery (CSRF)
An attacker could build a webpage form that creates a request against your site like this:
<form action="https://my-vulnerable-site.com/email/change" method="POST">
<input type="hidden" name="email" value="user@evil-site.com" />
</form>
<script>
document.forms[0].submit();
</script>
Suppose a user visits the attacker's page and is logged in to your site, assuming that your site uses a session cookie for authentication. In that case, their browser automatically includes their session cookie in the request.
The vulnerable website will process the request in the normal way, treating it as though it had been made by the user, and proceed to change their email address.
Subsequently, the attacker can change the user's password and gain access to the user's account. This type of attack — enabling an attacker to manipulate users into executing unintended actions — is known as Cross-Site Request Forgery (CSRF).
Mitigating CSRF Attacks
The strongest defense against CSRF attacks is incorporating a CSRF token into relevant requests. The CSRF token strategy usually works like this:
- The server sends the client a token.
- The client submits a form with the token.
- The server rejects the request if the token is invalid.
The token must meet the following criteria:
- Unpredictable and possessing high complexity, much like session tokens in general.
- Associated with the user's session.
- Thoroughly verified before executing the relevant action in each case.
You could create a custom CSRF middleware leveraging package like csrf for generating and validating CSRF tokens.
Threat: Insecure Dependencies
Let's face it, most Node.js applications need third-party libraries from npm or yarn for ease and speed of development. Sadly, this convenience comes with the risk of introducing unknown vulnerabilities to your application. (Granted, most maintainers address vulnerabilities in their packages and regularly release updated versions.)
Here are some steps you can take to try to avoid introducing insecure dependencies into your application.
Constantly Update Your Dependencies
You could use npm audit
or snyk to analyze your project’s dependencies tree and provide insights into any known vulnerabilities.
Verify Packages Before Use
Before integrating a library into your code, ensure it is stable and actively maintained, and review GitHub issues for that package. Additionally, examine its dependencies, reputation, reviews, and community support to guarantee the security and safety of your application. While these efforts can be demanding, they are essential to ensure the integrity of your integrated packages.
Additional Security Best Practices
Let's end with a few other general best practices to keep your Node app secure.
Logging and Monitoring
Providing visibility into system activities can enhance security best practices. I highly recommend using AppSignal to log and monitor your Node.js application because it is efficient and easy to integrate.
Check out my previous post on Async Stack Traces in Node.js where I demonstrated how to set up AppSignal for Express.js applications easily.
How can logging and monitoring your system help? You'll gain insights into:
- Unusual login attempts
- Unauthorized access
- Suspicious network traffic
- Data passing through a network (both tracking and analysis)
- Log entries
Run Node.js As a Non-root User
If your Node.js app operates with root user privileges, attackers can exploit any vulnerabilities, granting them full control over your machine. They could then carry out destructive actions against your application.
So, whether your backend runs on a dedicated server or Docker container, avoid using root users for Node.js. Olivier Lalonde's insights in the post Don't run Node.js as root! are noteworthy here.
Deny Access To JWT After A Password Reset
If a user is alerted and responds to unfamiliar access into their account by changing their password, blacklist the JWT previously issued to the client to avoid further access by the unknown actor.
Don't Expose Error Details In the Response Payload
Never send 500
error details to the client. Attackers can use that information to exploit the vulnerabilities in your application. Instead, send a generic message like "Something went wrong 😔".
Denial of Service (DoS)
Mitigate DoS attacks by using rate limiters for network throttling and libraries such as body-parser to limit body payload size.
Avoid Using Default Cookie Names
Don't use default names for cookies, as they might unintentionally disclose the technology stack your backend relies upon. By discerning the framework in use, attackers can exploit any particular vulnerabilities associated with it.
Instead, opt for custom cookie names.
And that's it!
Wrapping Up
In this post, we explored common security threats in Node.js applications and practical ways to mitigate them for production environments.
You can fortify your applications against evolving security risks by implementing these security measures and remaining vigilant.
Happy coding!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.
Posted on July 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024
November 29, 2024