Anything on the web is prone to attacks. No one can make picture-perfect applications within the web, but we can try our best to reduce the loopholes, vulnerabilities, and weaknesses in our application so the possibility of having a security breach will be minimized.
There are general abstract security measures that we can take to improve the security of web applications, and also there are technology-specific measures that we have to implement to ensure safety.
Since Node.js is used in many web applications around the world, I thought about writing on measures that are specific and unique to Node.js — and also about general security measures we can take.
The Open Web Application Security Project (OWASP) lists out some measures and recommendations we can take into consideration. They’ve categorized these measures into four main fields.
The 4 Types of Security Checks, According to OWASP
- Application security
- Error and exception handling
- Server security
- Platform security
1. Application Security
Application security mainly focuses on how to secure the runtime of the application from threats like unauthorized access, modification, etc. The following are some of the measures we can employ to ensure application security.
Limit Request Sizes
When our application is serving a user, it always has to buffer and parse some data sent by the user.
The data may be a URL, a JSON body, or multipart data. If there’s no limit to the sizes of these requests, attackers can send in large chunks of data to our application, and this may lead to the server running out of memory, out of CPU power, or out of disk space. So we need to have an upper limit on the request sizes sent from users. But URL data is very small and is measured in near-bytes or a few kilobytes. JSON data is around some kilobytes, and multipart data can go up to 20-30 megabytes.
These sizes depend upon the application use cases, usage, and the domain. Therefore, we need to manually specify different request limits for the different request types. We can easily do this for Express using the following steps.
app.use(express.urlencoded({ limit: “1kb” })); // these limits can be altered to your usage
app.use(express.json({ limit: “1kb” }));
app.use(express.multipart({ limit:”10mb” }));
app.use(express.limit(“5kb”)); // this will be valid for every other content type
But this approach isn’t enough because attackers can manually change the request size headers and bypass this validation. Therefore, upon processing the request, we need to manually check for this validation, too.
Avoid Creating Race Conditions That Impact Security
For blocking events, Node.js allows assigning callbacks to maximize the throughput. For I/O-related tasks, using callbacks will help in not blocking the event loop. Take a look at this very simple but quite faulty authentication script.
const fs = require('fs');
fs.readFile('/authentication.txt', (err, data) => {
// username = data.username;
// password = data.password;
// if username & password is valid -> authorize user
});
fs.unlinkSync(‘/authentication.txt’);
The above-shown code is an example of using a callback to read a file. Here, a race condition occurs between the callback and line seven. The winner of the race will completely change the output of the program flow. So if line seven gets executed first, the text file will be unlinked before the user gets authenticated. This may lead to serious faults or exceptions being thrown.
The following is a good way to perform the above task without any race conditions. We can write all of the operations that rely on each other in one nonblocking function. With this, we can guarantee the important operations are executed in the correct order.
const fs = require('fs');
fs.readFile('/authentication.txt', (err, data) => {
// username = data.username;
// password = data.password;
// if username & password is valid -> authorize user
// else -> false
fs.unlink('/authentication.txt', (err) => { // unlink is an asynchornous delete function
if (err) throw err;
});
});
Validate Inputs
Getting input from users is common in almost all of the web applications, so it is crucial those input values are validated. Not doing input validation or partially validating the input may lead to many different types of application vulnerabilities. Attackers can use these vulnerabilities to perform SQL injection, cross-site scripting (XSS), command injection, local/remote file inclusion, denial of service (DOS), directory traversal, LDAP injection, and many other injection attacks.
Defining SQL and LDAP Injections
- A SQL injection attack consists of insertion or “injection” of a SQL query via the input data from the client to the application. SQL commands are injected into data-plane input in order to affect the execution of predefined SQL commands. (Source: OWASP)
- An LDAP (light directory access protocol) injection is an attack used to exploit web-based applications that construct LDAP statements based on user input. When an application fails to properly sanitize user input, it’s possible to modify LDAP statements through techniques similar to a SQL injection.
The best way to validate input is by allowlisting the accepted inputs. Allowlist validation involves defining exactly what’s authorized and blocking everything else. But most of the time, this is somewhat hard. Therefore, we can first check against the expected input scheme and escape dangerous inputs. You can learn more about allowlisting, blacklisting, and input validation here. In node.js, there are multiple modules available to ease validation, such as validator.js and mongo-express-sanitize. An example of using validator is:
import validator from 'validator';
validator.isEmail('[email protected]'); //=> true
Preventing SQL injection is somewhat different than this. Attackers can pass a SQL query hidden inside a user input to the program. These queries can read our sensitive information from the database as well as result in our database being wiped out. Consider the following SQL query.
select username, password from users where id=$id
Assume an instance where the user inputs 2
or 1=1?
as the input.
select username, password from users where id=2 or 1=1
This would reveal all the data from the user table to the user. To avoid these situations, we have to use parameterized queries or prepared statements. All the available database modules provide the functionality to avoid those injections. The following is from the node-postgres module.
const Client = require(‘pg’)
const client = new Client()
await client.connect()
const query = ‘SELECT username, password FROM users WHERE id = $1’
const values = ['12213']
// callback
client.query(text, values, (err, res) => {
if (err) {
// error
} else {
// success
}
})
Command injection is another type of attack that can happen to a web application. With this, an attacker can run OS commands on our web server. Assume an instance which uses the child_process.exec
method to invoke the gzip
command that appends a user-supplied dynamic file path to construct the gzip
command.
child_process.exec(
‘gzip ‘ + req.query.file_path,
function (err, data) {
console.log(‘data: ‘, data);
}
);
Here, if an attacker appends ; rm -rf /
to the file_path
input, it’ll break out of the gzip
command and execute the deletion command, which will wipe out the server completely. Attackers can chain multiple commands by using characters like ;
, &
, &&
, |
, ||
, $()
, <
, >
, and >>
.
The reason for this is the exec
method spawns a new bin/sh
process and passes the commands to execute in the system shell. This is equivalent to giving the attacker a Bash interpreter with the same privileges that our application has.
So one way to prevent this is to decrease the privileges of our application. But a good way to avoid these is to use the methods execFile
or spawn
instead of exec
.
child_process.execFile(
‘gzip’,
[file_path],
function (err, data) {
console.log(data);
}
);
The commands appended to the file_path
input end up in the execFile
method’s second argument of type array
. Any malicious commands in user input are simply ignored or cause a syntax error if they’re not relevant to the target command. But these methods are also not 100 percent safe, and we have to allowlist some user input, which I mentioned earlier, to guarantee security.
Perform Output Escaping
This should be performed to avoid another type of injection attack named cross-site scripting (XSS), which I mentioned earlier. Here, the attacker stores malicious code in a web application by inputting malicious scripts into forums, message boards, and comments. And these scripts get transported to another victim when they visit the web page. The malicious code executes in the victim’s browser without their knowledge.
What Is a Cross-Site Scripting (XSS) Attack?
Here, in addition to validating the input from the users, we have to escape all of the HTML and JavaScript content shown to the user by our application. We can use the escape-html or node-esapi libraries to perform this task in node.js.
Log Application Activities
Logging isn’t a way to prevent attacks. But if our server gets attacked, we can use the logs to monitor what happened and identify the loophole the attacker used. Node.js has some modules, like winston or bunyan, to log application activity.
Monitor the event loop for heavy traffic
When our server is overwhelmed with traffic, our application may crash. Attackers can use this to load our server with traffic and make it crash. This is a type of denial of service (DoS) attack.
What Is a Denial-of-Service (DoS) Attack?
So we have to monitor heavy traffic, and if it crosses a certain threshold that our application can handle, we have to respond with “Server Too Busy”
to the rest of the requests. This way, our application won’t crash and will stay responsive under extreme load, and it’ll continue serving as many requests as possible.
We don’t have to do this manually. The toobusy-js module helps us do this easily.
var toobusy = require(‘toobusy-js’);
var express = require(‘express’);
var app = express();
app.use(function(req, res, next) {
if (toobusy()) {
res.send(503, “Server Too Busy”);
} else {
next();
}
});
Prevent Brute-force Attacks
Brute-force attacks mostly happen for login endpoints. Attackers can input thousands of random passwords to guess the correct password. They may be successful after thousands of tries and will be able to log into our server.
To prevent this, we can reduce the number of times a certain user can try to log in (using rate-limiter); we can lock the account of the user after several tries (using modules like Mongoose); we can gradually increase the response time for every login request from a certain user (using express-bouncer); we can also use CAPTCHAs to stop these attacks (using svg-captcha); we can potentially use hydra, a proof-of-concept tool to test how our application behaves in these scenarios.
Prevent HTTP-parameter Pollution
Supplying multiple HTTP parameters with the same name may cause an application to interpret values in unanticipated ways. Attackers can exploit these to input validation, trigger application errors, or modify internal variables values.
There are various modules available to prevent these types of attacks. Express has a middleware, HPP, for these kinds of scenarios where, when it’s used, it’ll ignore all values submitted for a parameter in req.query
and/or req.body
and just select the last parameter value submitted.
2. Error Handling
We all know error handling is a must in any application, and we all know how to handle errors and exceptions. But the problem comes when certain sensitive details are revealed through stack traces. Stack traces aren’t vulnerabilities by themselves, but providing debugging information as a result of operations that generate errors is considered a bad practice. We should always log the errors but not show them to the users.
3. Server Security
As the name suggests, server security concerns the security of the server. It mainly focuses on the protection of data and resources held on the servers. It comprises tools and techniques that help prevent intrusions, hacking, and other malicious actions.
Set Cookie Flags Appropriately
The importance of the secure use of cookies can’t be understated. Cookies are used to transmit session information in web applications. But careless use of these may end up revealing sensitive information to the outside. There are built-in cookie flags we can use to prevent these problems.
httpOnly
: Prevents the cookie from being accessed by client-side JavaScript. This is a good safety measure to be secured from XSS attacks.Secure
: Allows the cookie to be transmitted only if the communication protocol is HTTPSSameSite
: Prevents cookies from being sent in cross-site requests, which helps to protect against cross-site request forgery (CSRF) attacks
Here is some example code for cookie implementation (from OWASP).
var session = require(‘express-session’);
app.use(session({
secret: ‘your-secret-key’,
key: ‘cookieName’,
cookie: { secure: true, httpOnly: true, path: ‘/user’, sameSite: true} // setting the flags
}));
Use Proper Security Headers
There are certain HTTP response headers that we can use to increase the security of our application. Once they’re set, they can restrict modern browsers from running into easily preventable vulnerabilities. The following are some of them:
Strict-Transport-Security
: Enforces secure (HTTP over SSL/TLS) connections to the serverX-Frame-Options
: Protects against clickjacking (when an attacker uses multiple transparent or opaque layers to trick a user into clicking on a button or link on another page)X-XSS-Protection
: Enables the XSS filter built into most recent web browsersX-Content-Type-Options
: Prevents browsers from MIME-sniffing, or trying to guess which of the Multipurpose Internet Mail Extensions is present if it does not trust the declared content-typeContent-Security-Policy
: Prevents a wide range of attacks, including XSS and other cross-site injections
To implement these, Node.js has a module, helmet, that we can use.
var express = require(‘express’);
var helmet = require(‘helmet’);
var app = express();
app.use(helmet());
We can use this online checker to check whether our application has all the necessary headers.
4. Platform Security
Platform security refers to the security architecture, tools, and processes that ensure the security of an entire computing platform.
Keep the Packages Up to Date
The packages we use also have their loopholes and vulnerabilities. Once such a loophole is discovered, it’s only a matter of time before an attacker uses that to attack our website. But the package providers update these regularly with bug fixes and security updates. So we have to make sure we keep our platform up to date.
Also, we need to make sure we don’t use any third-party modules that have serious security issues. We can use Snyk, a tool to find and fix security vulnerabilities in open-source libraries and containers.
Be Safe From Evil Regexes
Evil regexes are some regular-expression implementations that may reach extreme situations that cause them to work very slowly. ([a-zA-Z]+)*
, (a+)+
, or (a|a?)+
are all vulnerable regexes, as a simple input like aaaaaaaaaaaaaaaaaaaaaaaa!
can cause heavy computations.
Attackers can use the above knowledge to look for applications that use regexes containing an evil regex and send a well-crafted input that’ll hang the system. Alternatively, if a regex itself is affected by user input, the attacker can inject an evil regex and make the system vulnerable. You can read more about this in Regular expression Denial of Service — ReDoS.
To prevent this, we can use a node.js tool called safe-regex. Assume we have a script like this one taken from npm:
var safe = require(‘safe-regex’);
var regex = process.argv.slice(2).join(‘ ‘);
console.log(safe(regex));
We can input various types of regexes to the above file to check whether they’re safe.
$ node safe.js ‘(x+x+)+y’
false
$ node safe.js ‘(beep|boop)*’
true
$ node safe.js ‘(a+){10}’
false
$ node safe.js ‘\blocation\s*:[^:\n]+\b(Oakland|San Francisco)\b’
true
This may sometimes give false positives, so we have to use it with caution.
Conclusion
So by implementing the above security measures, we can ensure our application is safer than most of the web applications out there. But we can’t guarantee safety because each day, new ways to attack web applications come into being. So we have to stay up to date and be careful.