Control security response headers with ExpressJS and HelmetJS

Posted by Roman Seidelsohn

Background

We, the frontend developers of Springer Nature’s Institutional Customer Experience (ICE) team, control several Express web servers that provide the web pages to our customers.

Recently, I randomly checked one of the websites using the online service provided by Security Headers. Security Headers is a web service that checks the response headers sent by a web server for security-related issues and provides a rating from A+ (best) to F (worst). For each security-related header that is present or absent, it provides further details to give users the information they need to increase the security level of their web server.

The results it gave for the server I tested were not too bad, but could be improved (we got a D grade on a scale of A++ to F).

It took me several tries to get the result I wanted. Along the way, I learned more about web server security which I would like to share in this article.

Upgrading the Helmet module

It turned out that we were already using Helmet, a plugin for the Express web server that helps secure it by setting HTTP response headers.

Helmet sets the response headers it deems necessary to provide an up-to-date and solidly secured web server as far as it’s response headers are concerned. The reason our server scored so poorly in the test was mainly because the version of Helmet we were using was out of date. The current version would automatically take care of the most important missing headers - we were using version 3.x, yet the current major version already reached 7!

However, without manual tuning, the website would most likely fail to load external resources such as scripts, images and frames. It would also not allow any inline scripts to run in the browser, which is a sensible basic setting. Bear in mind that you can use any of these security headers without losing the desired level of security - you can configure fine grained exceptions for every content security policy directive.

Manual modification of the default settings

One of the most important settings to change, unless you host all the resources yourself, is the Content Security Policy (CSP).

An excerpt from the Helmet website:

To configure this header, pass an object with a nested directives object. Each key is a directive name in camel case (such as defaultSrc) or kebab case (such as default-src). Each value is an array (or other iterable) of strings or functions for that directive. If a function appears in the array, it will be called with the request and response objects.

A good way to find out which setting your website needs is to simply use the default Helmet setup, e.g.

import express from "express";
import Helmet from "Helmet";

const app = express();

// Use Helmet with it's default settings:
app.use(helmet());

and then run the server and examine the error messages that are likely to appear in the browser console.

In this article I will go step by step through some of the problems I found on our server.

Content Security Policy (CSP) directives

The first error that was displayed in the console said (disclosed details have been replaced by the letters XYZ in this article):

Refused to load the script 'https://XYZ.springernature.com/XYZ/XYZ.js' because it violates the following Content Security Policy directive: "script-src 'self'".
Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

So this message already tells us that Helmet by default sets the Content Security Policy directive "script-src 'self'", but it seems that this is not enough, as here we want to load a JavaScript file from a domain that we trust. So we need do explicitly define the values for the CSP script-src directive:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com"]
        }
    }
}));

By adding the value *.springernature.com, we allow all subdomains of springernature.com to be used in the src attributes of our <script> tags. We can do this quite safely because we trust our subdomains to serve scripts; so even if a hacker could trick our pages into loading JavaScript from some untrusted domain, those scripts would not execute.

Note that the 'self' entry in the above example is enclosed in two sets of quotes, the inner ones being single quotes. This is necessary because predefined values (keywords) that can be used in the different CSP directives must be supplied in single quotes according to CSP specifications. When not used, the keywords will be considered as a part of a resource URL - URLs do not use any quotes. Therefore, we add our new entry here using just a pair of (double) quotes.

After this update and a recompile of our code, the server now loads the external script without complaint.

But there was also another error message in the console output, which looked a bit different:

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'".
Either the 'unsafe-inline' keyword, a hash ('sha256-AF490//jIflwN/2nTDszvAx/KI2V9GJG8gdwvGhO/zw='), or a nonce ('nonce-...') is required to enable inline execution.

This time the error complains about an inline script that the browser will not run because of the CSP. This prevents the execution of inline scripts that a malicious user might embed in the pages our web server serves. I think this is a good attitude of the default CSP settings, but we have an inline script that we want the browser to run.

Not understanding how to use the hash or a nonce here, I simply added the suggested ‘unsafe-inline’ keyword to our script-src directive. Since it’s a predefined term for the script-src values, it also needs to be enclosed in single quotes. I’ll come back to this later in the article.

Our helmet configuration now looks like this:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'"]
        }
    }
}));

After these changes, the external scripts could be loaded and the inline scripts were executed by the browser.

The next error that appeared in our particular case was:

Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' *.springernature.com 'unsafe-inline'".

In this case, it was a script we hosted ourselves (so the source was trustworthy), but it used an eval command, which is generally considered to be potentially harmful and should be avoided whenever possible. So the best way to deal with this problem would be to get rid of the eval command, but that is beyond the scope of this article. Instead, I will describe how I first dangerously decided to allow the use of eval' (and [some other commands that are prevented](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#unsafe_eval_expressions) unless ‘unsafe-eval’ has been added to your script-src' directive). But be aware that you generally should not allow the use of eval, and later in this document, I fix my previous decision. The error message already tells us that we need to add ‘unsafe-eval’ to our script-src` directive to get rid of the error:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"]
        }
    }
}));

This was all it took to get rid of all our `script-src’ directive errors.

Next came a slightly different error message:

analytics.js:36 Refused to connect to 'https://www.google-analytics.com/j/collect?XYZ' because it violates the following Content Security Policy directive: "default-src 'self'".
Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback.

Again, this error is related to a src, but in this case not for a <script> tag, but for some script interface trying to load data from Google Analytics, which we also use on this site. Again, this error message tells us about our options to get rid of the problem. We could either extend the default-src directive or use the more specific connect-src directive. Since changing the default-src directive would allow more than just using the call to Google Analytics through a connect-src, we will explicitly configure the connect-src:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"],
            "connect-src": ["'self'", "www.google-analytics.com"]
        }
    }
}));

Now that the browser is allowed to connect to Google Analytics (GA) via a script interface, a new error occurs as Google Analytics seems to be trying to load an image from the Google Analytics website. Although we have allowed the connect-src to be www.google-analytics.com, we have not allowed an image source to be anything other than 'self'. If we had allowed the GA domain for the more general default-src, then this would also include the domain to be used as img-src. But we want to be strict and have full control over what a browser is allowed to load or not, which is why we add an entry for the img-src as well:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com"]
        }
    }
}));

We got rid of all the error messages for now, but of course we need to check all the pages we have. And indeed, a new error appeared on another page:

Refused to load the image 'data:image/svg+xml;utf8,<svg focusable="false" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="XYZ"/></svg>' because it violates the following Content Security Policy directive: "img-src 'self' www.google-analytics.com".

This image URL comes from a background SVG image that we have defined in our CSS using a data URI. Even this is prevented by the Content Security Policy directives that Helmet sets by default, so we need to allow it for us as an image source as well:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com", 'data:']
        }
    }
}));

Note that 'data:' is also one of the predefined values (including the trailing colon), so it must be added to the values array in single quotes.

Having done this, I proceeded to the next pages and another error appeared on the console:

Refused to frame 'https://www.surveymonkey.com/' because it violates the following Content Security Policy directive: "default-src 'self'".
Note that 'frame-src' was not explicitly set, so 'default-src' is used as a fallback.

Yes, we use the Surveymonkey service on one of our pages to display a form. Surveymonkey does this by injecting an iframe into our page, and this requires the Content Security Policy directive frame-src, which is also covered by the more general default-src directive. But we don’t want to use the general default-src directive, as I explained earlier, but rather maintain finer-grained control. So we will now configure the frame-src directive as well:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com", 'data:'],
            "frame-src": ["www.surveymonkey.com"]
        }
    }
}));

These were the Content Security Policy directives. But Helmet offers more security related settings, so I’ll explain what we configured in addition.

Frameguard / X-Frame-Options

Helmet offers a setting called frameguard. This setting allows you to specify values for the X-Frame-Options header that will prevent the web page that comes with this header from being embedded in a frame by another web page. This is intended to prevent “Clickjacking” attacks. Currently it takes one of two values: deny or sameorigin. If you do not change this setting, then by default Helmet will send this header with the sameorigin value, allowing sites with the same origin as the current site to include it in a frame.

If you want to prevent even that, then set this to deny:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com", 'data:'],
            "frame-src": ["www.surveymonkey.com"]
        }
    },
    frameguard: {
        action: 'deny'
    }
}));

Now, any browser that supports this header will never display our site in a frame, no matter where. Be aware, though, that this then prevents your customers from viewing your website within an iframe. Therefore, you should eventually clarify this point so that you do not lock your customers out. That being said, the technique of integrating a foreign website inside an iframe and make it look as if it were original, is a well-known technique used by hackers. Through this technique they can take control of the actions of your users, whilst said users believe they are simply browsing your site as normal.

HTTP Strict-Transport-Security (HSTS)

Then there is an option that tells the browser to automatically prefer HTTPS over HTTP. It tells browsers that the site should only be accessed via HTTPS and that any future attempts to access it via HTTP should automatically be converted to HTTPS.

The header responsible for this is called Strict-Transport-Security and you should definitely set it. In the Helmet setup, you can use the strictTransportSecurity or hsts attributes interchangeably.

The hsts entry lets you can set the following attributes:

  • maxAge - the number of milliseconds the browser will remember to only request data from your site using HTTPS
  • includeSubDomains - a boolean value that tells the browsers whether to include subdomains to respect the strictTransportSecurity rule as well
  • preload - a boolean value (see explanation below) The preload attribute is not a standard, but a service provided by Google that modern browsers use. The supporting browsers preload a list of domains to request data from using HTTPS, even if they have never accessed those servers before. I recommend to use this value as well.

According to the documentation on the Mozilla Developer Network, the value for maxAge should be 63072000 (2 years), so we use that:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'unsafe-eval'"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com", 'data:'],
            "frame-src": ["www.surveymonkey.com"]
        }
    },
    frameguard: {
        action: 'deny'
    },
    hsts: {
        maxAge: 63072000, // The minimum recommended value by https://hstspreload.org is 63072000 (2 years)
        includeSubDomains: true,
        preload: true
    }
}));

This is now a good setup for our security-related headers - almost.

How to get rid of the unsafe-inline setting

Remember I said I was going back to the unsafe-inline option I had originally used because I did not understand the other two options the error message suggested I use instead?

I felt really bad about this “unsafe” setting, and it definitely is unsafe, so let’s now look at the two ways to avoid it.

Use a hash value in the script-src directive

The first alternative to using `unsafe-inline’ that the error message suggested was to use a hash. What’s even better: It provided the exact hash to use, so I can copy & paste and make our configuration look like this:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'sha256-AF490//jIflwN/2nTDszvAx/KI2V9GJG8gdwvGhO/zw='"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com", 'data:'],
            "frame-src": ["www.surveymonkey.com"]
        }
    },
    frameguard: {
        action: 'deny'
    },
    hsts: {
        maxAge: 63072000, // The minimum recommended value by https://hstspreload.org is 63072000 (2 years)
        includeSubDomains: true,
        preload: true
    }
}));

Note that the hash value must also be inserted into the array with single quotes around it, as suggested by the error message in the console. Like for the keywords, when you leave off the single quotes, the hash would be interpreted a part of a URL.

There are several different hash algorithms that produce hashes of different lengths. For our purposes, the SHA-256 algorithm is good enough, as it is used for creating a one-way hash, not for encryption. This 256 bits long hash value is calculated from every character between the opening and closing <script> tags of the inline script in question. There are online tools that will calculate this value for you, for example this inline script and style hasher. But there is a caveat: you have to use the complete contents of your script tag(s) (including white spaces) after compiling and possibly minifying your HTML. Otherwise, you would get the hash of the unminified source, which would not match the minified script code on your production website and thus the CSP would not allow it’s execution. This makes it a bit cumbersome to maintain - after every single change, even a whitespace, you have to recalculate the script hash.

I think it’s acceptable to use the value given in the error message in the console that you see after making a change to your code. So if you use the hash method, you need to make sure that you either always check the pages of your site and look for error messages in the browser console, or you automate this process in some way. This could be done passively by automating the check for errors or actively by calculating the hashes of the compiled scripts and inserting them into the CSP settings. Both methods are not trivial and beyond the scope of this post.

Use a nonce in the script-src directive

A Content Security Policy “nonce” is a randomly generated token that is used for exactly one request - so a new nonce has to be generated for every single request.

If you want to use a nonce, you need to set up a logic that automatically generates a new nonce per inline script for each request, and ensure that all these nonces are added to the response headers sent by your server. This is not a trivial task, but once it’s set up you won’t have to recalculate the hash value needed when using hashes instead.

Also note that this will prevent your pages from being cached, as each request will result in a different response.

Report only

One caveat of using a Content Security Policy on your server is that if you accidentally miss an entry for any of the directives - e.g. because it only takes effect on a production system - then things you need might fail to load.

Sometimes this can be very hard to test yourself - for example when loading a third-party resource behind a CDN.

Let’s say you are based in Italy. You may have blocked all domains by default (good!) but allowed it.example.net because that’s the domain the third-party CDN redirects your request to. However a user in Germany may be making the request of de.example.net and their request would fail because you block all domains by default.

This would be very hard to spot without using a CSP report server.

Therefore, it’s helpful to simulate the CSP and collect the errors it would yield if it were not simulated. For this use case, a special response header exists: The Content-Security-Policy-Report-Only. With Helmet, it is very easy to use this variant of the Content Security Policy: You simply add the reportOnly directive to it with the value of true.

In our example from above, changing the CSP to only “simulate” it’s settings, looks like this:

app.use(Helmet({
    contentSecurityPolicy: {
        directives: {
            "script-src": ["'self'", "*.springernature.com", "'unsafe-inline'", "'sha256-AF490//jIflwN/2nTDszvAx/KI2V9GJG8gdwvGhO/zw='"],
            "connect-src": ["'self'", "www.google-analytics.com"],
            "img-src": ["'self'", "www.google-analytics.com", 'data:'],
            "frame-src": ["www.surveymonkey.com"],
            "reportOnly": true
        }
    },
    frameguard: {
        action: 'deny'
    },
    hsts: {
        maxAge: 63072000, // The minimum recommended value by https://hstspreload.org is 63072000 (2 years)
        includeSubDomains: true,
        preload: true
    }
}));

Yet, adding the reportOnly setting doesn’t help - it would simply result in making the configured CSP useless, as it is then only simulated and there is no setup for where the report should go to.

The Content-Security-Policy-Report-Only response header needs a report-to /csp-violation-report-endpoint/ addition, where the /csp-violation-report-endpoint/ part is a relative or absolute URL the report will be sent to in JSON format.

Unfortunately, the developer of the Helmet module decided to not support the report-to entry, as it is not strictly related to improving security. But there is a plugin called “report-to” that can be used in addition the the CSP setup in Helmet. See it’s documentation for more details.

In order to make use of this feature, you need to setup a script that captures this JSON data and stores it for further inspection. Alternatively, you can use a paid service, for example Report-URI. Such a service may offer a free trial period; which may suffice for testing whether your CSP settings are good enough.

Check your setup

One last thing I want to mention: You regularly should check your CSP in production using an online service such as the CSP-evaluator. It may come up with valuable hints on weaknesses in your setup and how to improve (and why).

In summary

  • If you use Express to serve your website, you should also use the Helmet plugin in order to improve the security of your website against hacking attacks.
  • Rely on the defaults that Helmet provides until you encounter issues that require specific settings in your Content Security Policy. Helmet provides the best practices for you.
  • Always make sure you are using the latest version of Helmet, as new best practices will emerge in the future.
  • Adopt a “deny-by-default” mentality; block everything and only allow what you need. Be as specific as possible, e.g. by not allowing all subdomains of a particular domain using the asterisk (*) wildcard, and by restricting access to the full domain names that you know your site needs to access.
  • Be aware that your rules may need ongoing maintenance if you are using inline script securing with hashes.
  • If you disable Helmet for in development environments, be sure to turn it on and test your site by looking at the browser console output before deploying.
  • Be aware that there are sometimes issues in production that you may not see in development or staging environments, such as when the Google Tag Manager tries to dynamically load pixel images only in production.
  • Use the Content-Security-Policy-Report-Only header in the beginning to check if your CSP would break anything on your production server.

Happy securing.

  • Express, a well-known web server for NodeJS
  • Helmet, a plugin for Express that adds security related response headers
  • Content Security Policy (CSP), a good documentation of the CSP on the Mozilla Developer Network
  • “Clickjacking” attacks, a documentation from the Open Worldwide Application Security Project (OWASP) online community
  • Content Security Policy “nonce”, a documentation explaining the meaning and use of “nonce”s
  • report-to, a plugin for Helmet adding a report-to setting to the Content-Security-Policy-Report-Only response headers
  • Report-URI, an online service for collecting data sent through a Content-Security-Policy-Report-Only response header setup
  • Security Headers, an online scanner for your security headers
  • CSP-evaluator, a very useful tool for finding flaws in your CSP

Find this post useful, or want to discuss some of the topics?

About The Authors