JavaScriptSecurity

How to improve HTTP(S) security in Express.js applications

A starter guide to stopping malicious requests from affecting your server

Introduction

I wrote this article as a response to the numerous malicious HTTPS requests this website has received, specifically those targeted at common vulnerabilities, with the aim of sharing how to combat malicious requests and effectively use these techniques alongside other security measures.

Bear in mind that there's an endless array of topics to cover on this subject, and no article can reasonably have all answers. This article is not a catch-all guide for all security measures; it's aimed at improving security against a single type of vulnerability. Please make sure you read up on server security and try to get a good understanding of best practices before you attempt to run your own server.

Securing HTTP Headers

Starting with the most straightforward change, using the right HTTP headers can significantly impact website security. Specifically, the right HTTP headers can:

Improving HTTP header security in a Express application is as easy as using the helmet middleware.

Helmet helps you secure your Express apps by setting various HTTP headers. It's not a silver bullet, but it can help!

Start by installing the helmet package via NPM:

npm install helmet

Then import and mount the helmet middleware with app.use:

import express from "express";
import helmet from "helmet";

const app = express();

app.use(helmet());

You can also selectively enable helmet provided middleware, without the helmet wrapper:

import * as helmet from "helmet";

// ...

app.use(helmet.contentSecurityPolicy());
app.use(helmet.crossOriginEmbedderPolicy());
app.use(helmet.crossOriginOpenerPolicy());
app.use(helmet.crossOriginResourcePolicy());
app.use(helmet.dnsPrefetchControl());
app.use(helmet.expectCt());
app.use(helmet.frameguard());
app.use(helmet.hidePoweredBy());
app.use(helmet.hsts());
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.originAgentCluster());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.referrerPolicy());
app.use(helmet.xssFilter());

Or selectively disable helmet provided middleware, using the helmet wrapper:

// This disables the `contentSecurityPolicy` middleware but keeps the rest.
app.use(helmet({
  contentSecurityPolicy: false
}));

This also allows you to set custom values for specific HTTPS headers:

// This sets custom options for the `referrerPolicy` middleware.
app.use(helmet({
  referrerPolicy: {
    policy: "no-referrer"
  }
}));

Avoid Complex Data Structures

When dealing with data transfers, you should avoid using complex data structures to transfer data during requests. Doing so can improve latency, upload/download speed, maintainability and security.

For example, consider the following data structure for a product:

{
  "product": {
    "type": "shoe",
    "details": {
      "brand": "Generic",
      "name": "Generic Brand Trainers",
      "cost": {
        "amount": 84.99,
        "currency": "GBP"
      },
      "style": {
        "color": "Black",
        "fit": "High Top"
      }
    },
    "retailer": {
      "name": "Shop Name",
      "mode": "online-order"
    }
  }
}

Versus the simpler alternative:

{
  "type": "shoe",
  "brand": "Generic",
  "name": "Generic Brand Trainers",
  "cost": 84.99,
  "currency": "GBP",
  "color": "Black",
  "fit": "High Top",
  "retailer": "Shop Name",
  "modes": "online-order"
}

The second example requires fewer resources to transfer, has better legibility, and is more developer-friendly. In addition, sticking to simple types such as strings and numbers for values can also reduce complexity and, in turn, human error in the development stage.

Preventing HPP Attacks

HTTP Parameter Pollution (HPP) is a vulnerability exploited by injecting encoded query string delimiters in already existing parameters and usually comes in the form of HTTP(S) requests with (user-)modified query parameters. A basic example is an HTTP(S) request containing query parameters of types or values not supported by the application with the intention of causing a server error or data leaks.

For example, given the following request:

localhost:3000/example?foo[bar]=baz

The callback handling the route for /example will receive a request.query object that looks like this:

foo: {
  bar: "baz"
}

While requests like this do not seem dangerous at first glance, they can severely threaten APIs and routes configured to handle query parameters. They can also lead to server errors and leak information, both of which can be catastrophic for your site or service.

One way to protect your server against such attacks is to ensure that each key in the request.query object has the correct name and that each value is of the right type.

The simplest way to implement this is to check if these conditions are met using the callback function for your route (or using custom middleware preceding the callback for your route):

// An object containing known/allowed keys and their basic types
const allowed = {
  "key_1": "string"
};

// Shorthand for "Object.prototype.hasOwnProperty"
const hasOwnProperty = Object.prototype.hasOwnProperty;

// Check to see if a user provided key is unrecognised or its value is of an incorrect type
function not_allowed([key, value]) {
  // Return a boolean value
  return (
    // 1. Check to see if the key of an entry is in the "allowed" object
    !hasOwnProperty.call(allowed, key) ||
    // 2. Check to see if the value of the entry is correct
    typeof value !== allowed[key]
  );
}

// 1. Get the entries of "request.query"
// 2. Filter the entries using "check"
// 3. Check to see if there are any entries that failed the checks
//    (have unrecognised keys or incorrect value types)
app.get("/some-queries-allowed", (request, response) => {
  // There is an unrecognised key or a key has an invalid value
  if (Object.entries(request.query).filter(not_allowed).length > 0) {
    response.send("Response for fail...");
  } else {
    response.send("Response for success...");
  }
});

Compact version of the code above:

// An object containing known/allowed keys and their basic types
const allowed = {
  "key_1": "string"
};

// Check to see if there are any entries that failed the checks (have unrecognised keys or incorrect value types)
const not_allowed = ([key, value]) => !Object.prototype.hasOwnProperty.call(allowed, key) || typeof value !== allowed[key];

app.get("/some-queries-allowed", (request, response) => {
  // There is an unrecognised key or a key has an invalid value
  if (Object.entries(request.query).filter(not_allowed).length > 0) {
    response.send("Response for fail...");
  } else {
    response.send("Response for success...");
  }
});

Note: Avoid (re)defining functions in loops and other functions!

Checking Request Bodies

Data and objects (commonly JSON) provided in the request.body object also contain user-defined information, check its contents before allowing your application to process the data.

app.get("/request-body-check", (request, response) => {
  // Shorthand for "request.body"
  const body = request.body;

  // Check if any data was passed in the request body
  if (Object.keys(body).length > 0) {
    if (
      // If "request.body" object has a key called "letter" that is of type string
      Object.prototype.hasOwnProperty.call(body, "letter") && typeof body.letter === "string" &&
      // If the value of the "letter" key contains (one) English letter (a-z & A-Z)
      /^[a-z]{1}$/i.test(body.letter)
    ) {
      response.send("Response for success...");
    } else {
      response.send("Response for fail...");
    }
  } else {
    response.send("Response for no value...");
  }
});

Compact version of the code above:

app.get("/request-body-check", (request, response) => {
  // Check if any data was passed in the request body
  if (Object.keys(request.body).length > 0) {
    // 1. If "request.body" object has a key called "letter" that is of type string
    // 2. If the value of the "letter" key contains (one) English letter (a-z & A-Z)
    if (Object.prototype.hasOwnProperty.call(request.body, "letter") && typeof request.body.letter === "string" && /^[a-z]{1}$/i.test(request.body.letter)) {
      response.send("Response for success...");
    } else {
      response.send("Response for fail...");
    }
  } else {
    response.send("Response for no value...");
  }
});

Always check and validate user input, regardless of importance or type.

request.body is based on user-controlled input, all properties and values in this object are untrusted and should be validated before trusting.

Restricting HTTPS Methods

Although the app.all and app.use handlers can be convenient during development, it's better to stick more specific handlers like app.get and app.post in conjunction with a global filter (middleware) depending on the request methods you allow across your website.

In this example post we'll only allow requests via the HTTP GET, POST and HEAD methods.

// Global middleware to limit the types of requests this server accepts
app.use((request, response, next) => {
  if (request.method !== "GET" && request.method !== "POST" && request.method !== "HEAD") {
    response.send("Response for incorrect HTTP method...");
  } else {
    next();
  }
});

// Using specific request methods for each route and type of request:

// GET requests to "/"
app.get("/", (request, response) => {
  response.send("Response for '/'...");
});

// GET requests to "/example"
app.get("/example", (request, response) => {
  response.send("Response for GET '/example'...");
});

// POST requests to "/example"
app.post("/example", (request, response) => {
  // Check request body!
  response.send("Response for POST '/example'...");
});

// HEAD requests to "/example"
app.head("/example", (request, response) => {
  response.send("Response for HEAD '/example'...");
});