3
\$\begingroup\$

To practice the very basics of node back-end, I'm implementing the most basic server possible for static content, using only the core node modules:

  • http
  • fs
  • path

With the following server requirements:

  • Can serve any http file inside the public directory.
  • Accepts only GET requests, 404 for anything else.
  • Accepts only .html extensions, 404 for anything else.
  • Use public/not-found.html for 404s.

These requirements are arbitrary, just to include some very basic logic in the request and response handling.

The working solution I'm presenting for code review is the following:

const fs = require('fs')
const http = require('http')
const path = require('path')

const hostname = 'localhost'
const port = 3000
const runLog = `Server running ar http://${hostname}:${port}`
const rootPath = '../public'
const errorPath = `${rootPath}/not-found.html`

const server = http.createServer((req, res) => {
  const fileUrl = req.url === '/' ? '/index.html' : req.url
  const filePath = path.resolve(`${rootPath}${fileUrl}`)
  res.setHeader('Content-Type', 'text/html')

  if (reqIsOk(req, filePath)) {
    res.statusCode = 200
    fs.createReadStream(filePath).pipe(res)
  } else {
    res.statusCode = 404
    fs.createReadStream(errorPath).pipe(res)
  }
})

function reqIsOk (req, filePath) {
  const fileExt = path.extname(filePath)
  return req.method === 'GET' && fileExt === '.html' && fs.existsSync(filePath)
}

server.listen(port, hostname, () => {
  console.log(runLog)
})

I'm looking for all kind of feedback, from style, code smells, anti-patterns, anything that can be improved is very welcome.

  • Are the fs and path packages needed? Do they simplify things enough to justify the inclusion?
  • Can this be written more concisely?
  • Is the ternary operator justified? Would you keep it here, or is an if construct preferred?
\$\endgroup\$
3
\$\begingroup\$

Are the fs and path packages needed? Do they simplify things enough to justify the inclusion?

fs is needed. How else would you get your data down the line without reading it in? path could be replaced with your own path utility library, but I wouldn't waste time reinventing that when a built-in one already exists.

Can this be written more concisely?

This is probably as concise as you can get it. Any further would probably make it unreadable.

Is the ternary operator justified? Would you keep it here, or is an if construct preferred?

Yep, ternary is fine. It's the check that might need improvement. That's because you're only tacking on index.html if the path is /. In other web servers, any path that ends in / assumes that index.html will be tacked on.

const filePath = path.resolve(`${rootPath}${fileUrl}`)

So one of the dangers when writing a web server is directory traversal. It's when your path resolver (in your case, this line) resolves to a path outside the web directory, and your server just willingly serves it. You'll need to check if the path resolved is still in your web directory.

res.setHeader('Content-Type', 'text/html')

function reqIsOk (req, filePath) {
  const fileExt = path.extname(filePath)
  return req.method === 'GET' && fileExt === '.html' && fs.existsSync(filePath)
}

A static server can be more than just HTML. Replace the hardcoded text/html with a value coming from a map of file extension to mimetype.

\$\endgroup\$
3
\$\begingroup\$

As mentioned in Joseph's excellent answer, your server is vulnerable to a path-traversal attack. If I send it a request with enough ..s in it, I can reach the root directory, and thus any directory from there.

E.g. if your web root is /var/www/root, and I request the URI ../../usr/share/doc/bc/bc.html, I will get the HTML documentation of the bc command from your PC. In fact, I can get any HTML document from your PC (including e.g. a bookmarks.html from a browser).

You are asking (bold emphasis mine)

I'm looking for all kind of feedback, from style, code smells, anti-patterns, anything that can be improved is very welcome.

So, I will answer your question in a direction that you probably didn't intend but might be interesting for you anyway.

"Real" webservers support a lot of additional features, for example:

  • Virtual Hosts
  • Access Control
  • Server-side scripting
  • Redirection
  • Compression

to name just a few.

Obviously, your web server is only intended as a simple exercise, and all of these features require complex configuration files and massive machinery … or do they?

It turns out, there are actually some tiny web servers that support some or all of these features in a clever way. For example, Felix von Leitner (fefe)'s fnord (discontinued) and gatling, or ACME Labs' thttpd. Especially fefe's web servers employ Unix filesystem semantics in "interesting" ways to avoid needing any configuration files.

Virtual Hosts

Configuring a Virtual Host in Gatling is easy: it's just a directory. Gatling does not serve files from the root of the web server directory, rather, it looks for a directory name that matches the Host HTTP header. So, if a browser sends a GET request for /foo/bar.html on Host: 192.168.1.1:80, Gatling will serve the file $WEB_ROOT/192.168.1.1:80/foo/bar.html.

If you have ever needed to configure Virtual Hosts in Apache, you will appreciate how simple this is:

mkdir -p 192.168.1.1:80/foo
touch 192.168.1.1:80/foo/bar.html

Boom, you're done.

Access Control

Access Control is a little trickier. But all three of the web servers I mentioned, have a really neat security feature that I wish more web servers had. Most web servers only care whether they themselves are allowed to read the file they are serving. However, thttpd, fnord, and gatling will only serve files that are explicitly world-readable, and they will only serve from directories that are explicitly word-accessible. They will also only generate directory listings for directories that are world-readable and will only show files within that listing that are world-readable.

It is sometimes surprising to people when web servers make files readable to the world that are not world-readable.

Note that this would also at least somewhat alleviate the path-traversal attack, since now I would only be able to access world-readable files in world-accessible directories.

Server-side scripting

In Gatling, any file that is executable will not be served as-is, but it will instead be executed, and the output of that file will be served. Specifically, it supports a subset of CGI (RFC 3875).

So, all you need to do to set up scripting in gatling, is chmod +x.

Redirection

In Gatling, symbolic links signify redirects. Remember that the target of a symbolic link is just a path. It doesn't actually have to exist.

So, if you want to set up a redirect from /search.html to https://google.com/, the way you would do that in Gatling is simply this:

ln -s https://google.com/ search.html

Again, compare this to redirects in Apache, or in a typical routing engine of a typical web framework.

Compression

At least Gatling and thttpd also support compression. I.e. if the client indicates that it supports deflate compression, and it requests the path /foo/bar/baz.html, they will first look for a file named /foo/bar/baz.html.gz and serve that if it exists.

These are just a couple of ideas how to improve and extend your tiny web server. Most of these are additional features, and thus not really in scope for a simply Code Review, but I believe that at least the "only serve world-readable files out of world-accessible directories" part would be a worthwhile addition and increase the security and usability. (Of course, you also need to fix the path-traversal attack identified by Joseph.)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.