Security
Description
A list of best practices and guidelines to follow during the development of Node.js applications at CRUK.
Checklist
Do | Don't |
---|---|
✔️ Perform validation on input | ❌ Create unnecessary API endpoints |
✔️ Use Anti-CSRF Tokens | ❌ Return unnecessary data |
✔️ Prevent HTTP Parameter Pollution | ❌ Return explicit errors for public facing calls |
✔️ Use access control lists | |
✔️ Handle uncaught exceptions | |
✔️ Use security headers |
Recommendations
Input validation
Input validation is a crucial part of application security. Input validation failures can result in many different types of application attacks, such as: SQL Injection, Cross-Site Scripting, Command Injection, Local/Remote File Inclusion, Denial of Service, Directory Traversal, LDAP Injection, etc.
In order to avoid these attacks, input to your application on both frontend and backend (e.g. form inputs, API payloads, lambda payloads) should be sanitized first. Inputs should be checked against expected input schema and dangerous inputs should be escaped. A popular package used within the department is Zod which can be used for validating complex input structures and return customised error messages. Another benefit of this package is that when using TypeScript, it returns typed data structures that pass validation.
Zod provides many validation options based on common standards, for example, validations on email address. Note that some of the rules in these methods may be more or less strict than is required on frontend input fields or downstream services consuming application data. Use these methods only when the built-in validations are suitable for your use case.
Command Injection
An injection vulnerability that manifests when application code sends untrusted user input to an interpreter as part of a command or query.
Example:
app.get("/", (req, res) => {
child_process.exec("gzip " + req.query.file_path, (err, data) => {
console.log("err: ", err);
console.log("data: ", data);
});
res.send("Hello World!");
});
To exploit the injection vulnerability an attacker can append a command, e.g rm -rf /
to the file_path input.
This allows the attacker to break out of the gzip command context and execute a malicious command that deletes all files on the server.
To prevent Command Injection, as well as validating user input, you could also:
- Use EXECFILE or SPAWN instead of EXEC spawn and execFile method signatures force developers to separate the command and its arguments
- Limit user privileges
Use Anti-CSRF tokens
Cross-Site Request Forgery (CSRF) aims to perform authorized actions on behalf of an authenticated user, while the user is unaware of this action. CSRF attacks are generally performed for state-changing requests like changing a password, adding users or placing orders. CSRF protection is only necessary when using cookies / stateful auth, it does not apply to react spa using cognito / jwt tokens
Many projects use the csurf library on the server side to add mitigation against CSRF attacks.
Remove unnecessary routes
A web application should not contain any page that is not used by users, as it may increase the attack surface of the application. Therefore, all unused API routes should be disabled.
Prevent HTTP Parameter Pollution
HTTP Parameter Pollution (HPP) is an attack in which attackers send multiple HTTP parameters with the same name and this causes your application to interpret them in an unpredictable way. When multiple parameter values are sent, Express/NodeJs populates them in an array. In order to solve this issue, you could use hpp When used, this module will ignore all values submitted for a parameter in req.query and/or req.body and just select the last parameter value submitted.
Only return what is necessary
In order to avoid data leaks, you should only return the specific fields required to the front end, and avoid returning extra information, especially when dealing with Personally identifiable information (PII).
Example:
const sanitizeUser = (user) => {
return {
id: user.id,
username: user.username,
fullName: user.fullName,
};
};
Implement strong authentication
Having a broken, weak, or incomplete authentication mechanism is ranked as the second most common vulnerability. It’s probably due to the fact that many developers think about authentication as “we have it, so we’re secure.” In reality, weak or inconsistent authentication is easy to bypass. One solution is to use existing authentication solutions like OAuth.
If there is a strong preference to use native Node.js authentication solutions, please follow these guidelines:
- When creating passwords, don’t use the Node.js built-in crypto library; use Bcrypt or Scrypt modules instead.
- Make sure to limit failed login attempts, and don’t tell the user if it’s the username or password that is incorrect. Instead, return a generic “incorrect credentials” error. Use proper session management policies.
- 2FA authentication is also recommended.
Logging
For Logging guidelines, please visit Monitoring
Error & Exception Handling
Good error handling can be critical in order to avoid data leaks and other vulnerabilities. Please see our error & exception handling guidelines listed below:
Avoid errors that reveal too much information about the system
- Don’t return the full error object to the client. It can contain information that you don’t want to expose, such as paths, another library in use, or perhaps even secrets.
- Wrap routes with the catch clause and don’t let Node.js crash when the error was triggered from a request. This prevents attackers from finding malicious requests that will crash your application and sending them over and over again, making your application crash constantly.
- Don’t directly expose your Node.js app to the Internet. Use some component in front of it, such as a load balancer, a cloud firewall or gateway, or a web server. This will allow you to rate limit DoS attacks one step before they hit your Node.js app.
Handle uncaught exceptions
Node.js behaviour for uncaught exceptions is to print current stack trace and then terminate the thread. However, Node also allows customization of this behaviour with a global object named process that is available to all Node.js applications: the EventEmitter
object.
In case of an uncaught exception, uncaughtException event is emitted and it is brought up to the main event loop. In order to provide a custom behaviour for uncaught exceptions, you can bind to this event. However, resuming the application after such an uncaught exception is strongly discouraged. The correct use of 'uncaughtException' is to perform synchronous cleanup of allocated resources (e.g. file descriptors, handles, etc) before shutting down the process.
It is important to note that when displaying error messages to the user in case of an uncaught exception, detailed information like stack traces should not be revealed to the user. Instead, custom error messages should be shown to the users in order not to cause any information leakage.
process.on("uncaughtException", (err) => {
// clean up allocated resources
// log necessary error details to log files
process.exit(); // exit the process
});
Listen to errors when using EventEmitter
When using EventEmitter, errors can occur anywhere in the event chain. Normally, if an error occurs in an EventEmitter object, an error event that has an Error object as an argument is called. However, if there are no attached listeners to that error event, the Error object that is sent as an argument is thrown and becomes an uncaught exception. In short, if you do not handle errors within an EventEmitter object properly, these unhandled errors may crash your application. Therefore, you should always listen to error events when using EventEmitter objects.
import { EventEmitter } from "events";
const firstEmitter = new EventEmitter();
firstEmitter.emit("My first event");
emitter.on("error", (err) => {
//Perform necessary error handling here
});
Handle errors in asynchronous calls
Errors that occur within asynchronous callbacks are easy to miss. Therefore, as a general principle first argument to the asynchronous calls should be an Error object. Also, express routes handle errors itself, but it should be always remembered that errors occurred in asynchronous calls made within express routes are not handled, unless an Error object is sent as a first argument.
Errors in these callbacks can be propagated as many times as possible. Each callback that the error has been propagated to can ignore, handle or propagate the error.
Set cookie flags appropriately
Generally, session information is sent using cookies in web applications. However, improper use of HTTP cookies can render an application to several session management vulnerabilities. Some flags can be set for each cookie to prevent these kinds of attacks. httpOnly, Secure and SameSite flags are very important for session cookies. httpOnly flag prevents the cookie from being accessed by client-side JavaScript. This is an effective counter-measure for XSS attacks. Secure flag lets the cookie to be sent only if the communication is over HTTPS. SameSite flag can prevent cookies from being sent in cross-site requests that helps protect against Cross-Site Request Forgery (CSRF) attacks. Apart from these, there are other flags like domain, path and expires. Setting these flags appropriately is encouraged, but they are mostly related to cookie scope not the cookie security.
Use security headers
There are several different HTTP security headers that can help you prevent some common attack vectors. These are listed below:
Strict-Transport-Security
HTTP Strict Transport Security (HSTS) dictates browsers that the application can only be accessed via HTTPS connections.
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.
X-XSS-Protection
As described in the XSS Prevention Cheat Sheet, this header should be set to 0 to disable the XSS Auditor. An issue was created in the helmetjs project to be able to set the header to 0. Once it is updated, this section will be updated to inform the user to disable the XSS auditor properly using helmetjs.
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 behaviour and tell the browser not to change MIME types specified in Content-Type header
Content-Security-Policy
Content Security Policy is developed to reduce the risk of attacks like Cross-Site Scripting (XSS) and Clickjacking. It allows content from a list that you decide. It has several directives each of which prohibits loading specific type of a content. You can refer to Content Security Policy Cheat Sheet for detailed explanation of each directive and how to use it.
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.