Securing against filesystem hacking exploits
For a Node app to be insecure, there must be something an attacker can interact with for exploitation purposes. Due to Node's minimalist approach, the onus is mostly on programmers to ensure their implementation doesn't expose security flaws. This recipe will help identify some security risk anti-patterns that could occur when working with the filesystem.
Getting ready
We'll be working with the same content
directory as in the previous recipes, but we'll start a new insecure_server.js
file (there's a clue in the name!) from scratch to demonstrate mistaken techniques.
How to do it...
Our previous static file recipes tend to use path.basename
to acquire a route, but this flat levels all request. If we accessed localhost:8080/foo/bar/styles.css
, our code would take styles.css
as the basename
and deliver content/styles.css
to us. Let's make a subdirectory in our content
folder, call it subcontent
, and move our script.js
and styles.css
files into it. We'd need to alter our script and link tags in index.html:
<link rel=stylesheet type=text/css href=subcontent/styles.css> <script src=subcontent/script.js type=text/javascript></script>
We can use the url
module to grab the entire pathname
. So let's include the url
module in our new insecure_server.js
file, create our HTTP server, and use pathname
to get the whole requested path:
var http = require('http'); var path = require('path'); var url = require('url'); var fs = require('fs'); http.createServer(function (request, response) { var lookup = url.parse(decodeURI(request.url)).pathname; lookup = (lookup === "/") ? '/index.html' : lookup; var f = 'content' + lookup; console.log(f); fs.readFile(f, function (err, data) { response.end(data); }); }).listen(8080);
If we navigate to localhost:8080
, everything works great. We've gone multilevel, hooray. For demonstration purposes, a few things have been stripped out from previous recipes (such as fs.exists)
, but even with them, the following code presents the same security hazards:
curl localhost:8080/../insecure_server.js
Now we have our server's code. An attacker could also access /etc/passwd
with a few attempts at guessing its relative path:
curl localhost:8080/../../../../../../../etc/passwd
In order to test these attacks, we have to use curl or another equivalent because modern browsers will filter these sorts of requests. As a solution, what if we added a unique suffix to each file we wanted to serve and made it mandatory for the suffix to exist before the server coughs it up? That way, an attacker could request /etc/passwd
or our insecure_server.js
because they wouldn't have the unique suffix. To try this, let's copy the content
folder and call it content-pseudosafe
, and rename our files to index.html-serve, script.js-serve
, and styles.css-serve
. Let's create a new server file and name it pseudosafe_server.js
. Now all we have to do is make the -serve
suffix mandatory:
//requires section...
http.createServer(function (request, response) {
var lookup = url.parse(decodeURI(request.url)).pathname;
lookup = (lookup === "/") ? '/index.html-serve' : lookup + '-serve';
var f = 'content-pseudosafe' + lookup;
For feedback purposes, we'll also include some 404
handling with the help of fs.exists
.
//requires, create server etc fs.exists(f, function (exists) { if (!exists) { response.writeHead(404); response.end('Page Not Found!'); return; } //read file etc
So let's start our pseudosafe_server.js
file and try out the same exploit:
curl -i localhost:8080/../insecure_server.js
We've used the -i
argument so that curl will output the headers. What's the result? A 404
, because the file it is actually looking for is ../insecure_server.js-serve
, which doesn't exist. So what's wrong with this method? Well it's inconvenient and prone to error. However, more importantly an attacker can still work around it!
curl localhost:8080/../insecure_server.js%00/index.html
And voila! There's our server code again. The solution to our problem is path.normalize
, which cleans up our pathname
before it gets to fs.readFile
.
http.createServer(function (request, response) {
var lookup = url.parse(decodeURI(request.url)).pathname;
lookup = path.normalize(lookup);
lookup = (lookup === "/") ? '/index.html' : lookup;
var f = 'content' + lookup
Prior recipes haven't used path.normalize
, yet they're still relatively safe. path.basename
gives us the last part of the path, so any leading relative directory pointers (../) are discarded, thus preventing the directory traversal exploit.
How it works...
Here we have two filesystem exploitation techniques: the relative directory traversal and poison null byte attacks. These attacks can take different forms, such as in a POST request or from an external file. They can have different effects. For example, if we were writing to files instead of reading them, an attacker could potentially start making changes to our server. The key to security in all cases is to validate and clean any data that comes from the user. In insecure_server.js
, we pass whatever the user requests to our fs.readFile
method. This is foolish because it allows an attacker to take advantage of the relative path functionality in our operating system by using ../
, thus gaining access to areas that should be off limits. By adding the -serve
suffix, we didn't solve the problem. We put a plaster on it which can be circumvented by the poison null byte. The key to this attack is %00
, which is a URL hex code for the null byte. In this case, the null byte blinds Node to the ../insecure_server.js
portion, but when the same null byte is sent through to our fs.readFile
method, it has to interface with the kernel. However, the kernel gets blinded to the index.html
part. So our code sees index.html
but the read operation sees ../insecure_server.js
. This is known as null byte poisoning. To protect ourselves, we could use a regex
statement to remove the ../
parts of the path. We could also check for the null byte and spit out a 400 Bad Request
statement. However, we don't need to, because path.normalize
filters out the null byte and relative parts for us.
There's more...
Let's further delve into how we can protect our servers when it comes to serving static files.
Whitelisting
If security was an extreme priority, we could adopt a strict whitelisting approach. In this approach, we would create a manual route for each file we are willing to deliver. Anything not on our whitelist would return 404
. We can place a whitelist
array above http.createServer
as shown in the following code:
var whitelist = [ '/index.html', '/subcontent/styles.css', '/subcontent/script.js' ];
Inside of our http.createServer
callback, we'll put an if
statement to check if the requested path is in the whitelist
array:
if (whitelist.indexOf(lookup) === -1) { response.writeHead(404); response.end('Page Not Found!'); return; }
That's it. We can test this by placing a file non-whitelisted.html
in our content
directory.
curl -i localhost:8080/non-whitelisted.html
The preceding command will return 404
because non-whitelisted.html
isn't on whitelist.
Node-static
https://github.com/joyent/node/wiki/modules#wiki-web-frameworks-static has a list of static file server modules available for different purposes. It's a good idea to ensure that a project is mature and active before relying on it to serve your content. Node-static is a well developed module with built-in caching. It's also compliant with the RFC2616 HTTP standards specification. This defines how files should be delivered over HTTP. Node-static implements all the essentials discussed in this chapter and more besides. This piece of code is slightly adapted from the node-static Github page at https://github.com/cloudhead/node-static:
var static = require('node-static'); var fileServer = new static.Server('./content'); require('http').createServer(function (request, response) { request.addListener('end', function () { fileServer.serve(request, response); }); }).listen(8080);
The preceding code will interface with the node-static
module to handle server-side and client-side caching, use streams to deliver content, and filter out relative requests and null bytes, among other things.
See also
Preventing cross-site request forgery discussed In Chapter 7, Implementing Security, Encryption, and Authentication
Setting up an HTTPS web server discussed In Chapter 7, Implementing Security, Encryption, and Authentication
Deploying to a server environment discussed In Chapter 10, Taking It Live
Cryptographic password sashing discussed In Chapter 7, Implementing Security, Encryption, and Authentication