Content Security Policy: How to create an Iron-Clad nonce based CSP3 policy with Webpack and Nginx

A Content Security Policy helps prevent XSS (Cross Site Scripting) attacks by limiting the way content is served from different sources and from where.

In this Article, I will provide a step by step process on how to implement a CSP3 compliant strict-dynamic CSP policy and properly apply it using Webpack and Nginx to serve static content. This guide can be followed with whatever file server you use.

By the end of this guide you will have a green CSP checkmark from the CSP Evaluator.

How bad is it out there? Really really bad

Content Security Policies have been around for many years now, yet there is shockingly poor documentation and guides around the internet on how to actually implement it. In fact, to see how poorly the online community understands CSP just download the “CSP Evaluator” Extension on Google Chrome and browse around.

What will you notice? First on this very website you’ll see that Medium has a script-src policy of ‘unsafe-eval’ ‘unsafe-inline’ about: https: ‘self’. Basically this policy is the same as having no policy. It allows any resource (as long as it’s https) to inject strings as code into this website via eval opening up medium.com to XSS attacks and other attack vectors.

WhiteLists, Whitelists, and more Whitelists

The CSP1 and CSP2 spec provided the concept of whitelists. Meaning every single domain that serves or displays on a page must be whitelisted in the CSP policy. The end result were incredibly long whitelists that were difficult or impossible to manage and keep up to date.

They also are not secure. There are also a large number of CSP bypasses and the use of JSONP which is prevalent in youtube and angular libraries that can be used to inject scripts from a seemingly trusted source. Turns out this isn’t so uncommon as expressed in this famous Research paper which motivated the creation of strict-dynamic in CSP3

Strict-Dynamic: a simpler more secure way

Strict-dynamic was proposed by W3C and has since been adopted by every browser except for Safari, Safari will likely include it in its next major release which is unfortunately annual. It gets rid of the script-src whitelist requirement and replaces it with a cryptographic nonce. In fact with strict-dynamic, all whitelist items are ignored, as long as the browser accepts strict-dynamic, otherwise the whitelist items are used. Instead strict-dynamic will allow any script as long as it matches an included cryptographic nonce or hash.

Nonce and the failure of existing Webpack modules

It is vital that the nonce is uniquely generated for each page load. It cannot be guessable. As long as the nonce is random, strict-dynamic shuts out XSS attacks. If the nonce is deterministic or static, it is useless and defeats the purpose of the CSP policy. It’s why you should 100% avoid using Slack’s Webpack module for CSP, it does not work and will not protect your website. Avoid it. They may update it at some point to create random nonces, but the way it is built currently precludes that possibility as it can only create a nonce at build time instead of at runtime.

You may also run into Google’s attempt at this issue. They instead automatically hash the source files at build time and provide that hash in the html meta policy. While this is actually a secure methodology, it will fail when you still need to use other scripts in your app that aren’t served by webpack. Once you add a nonce based policy with your web server, it will conflict with Google’s policy as the script they use to inject the hash will be rejected.

While CSP policies are allowed in the HTML Meta page, it is impossible to set up the report-uri there. That means that when a user experiences a failure, there will be no logging system and it will fail silently. For that reason it is generally recommended to use a Content-Security-Policy header from your web server instead.

WALKTHROUGH

Define the Policy

default-src 'self';
script-src 'nonce-{random}' 'strict-dynamic' 'unsafe-inline' https:;
object-src 'none';
base-uri 'self';
report-uri https://example.com/csp

This should be the starting point. Strict-dynamic only applies to the script-src entry. That means you will still need to white list other entries. All non existing entries will fallback to default-src and you will see an error if they are served from a different domain. See all of the available entries here . You might also consider adding fallback whitelist entries on the script-src to cover Safari users until the next release (In this case, any domain will be allowed to serve inline content as long as they are from an https source on Safari, not very secure, a whitelist would be better). For other browsers, only nonce-{random} and strict-dynamic is necessary, the rest will be ignored.

Add the nonce placeholder to your html template

Now we need to set a fresh window.nonce on every pageload so it can be applied to all of the webpack generated scripts and bundles. To do that we create a placeholder with any format, in this case I chose **CSP_NONCE**. Note that the script surrounding the window.nonce also needs the place holder or it will be rejected by the strict-dynamic policy

<!DOCTYPE html>
<html>
<head>
<title><%= htmlWebpackPlugin.options.title %></title>
<script nonce="**CSP_NONCE**">
window.nonce = "**CSP_NONCE**";
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

Apply the CSP policy and populate the nonce placeholder

Next we hop over to Nginx where we create a variable and apply it to the header. I use a variable because it allowed me to organize the CSP headers by section, it also allows me to easily separate development CSP and production CSP which have slightly different requirements due to http/s and devtools. In my real life project, it looks something like this:

"'[email protected]' ${defaultsrc} ${imgsrc} ${connectsrc} ${stylesrc} ..."

Then we need to actually turn ‘nonce-random’ into a cryptographically random string. Luckily nginx provides that out of the box with ‘$request_id” which in the CSP policy would look like

'nonce-$request_id'

server{
set csp "script-src 'nonce-$request_id' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; base-uri 'self'; default-src 'self'; report-uri https://example.com/csp"
..
..
location / {
..
add_header Content-Security-Policy "${csp}"
try_files /path/to/index.html =404;
}

What about serving the content? Nginx isn’t a file server that supports templating, most of the information out there suggests using a third party node server to handle the template. But actually nginx supports this just fine. It is not a default module so you will need to make sure you’ve built nginx using the. “ — with-http_sub_module” configuration. If you use the official nginx docker container (or openresty), it is included by default.

Now that we have the sub_module enabled we can add the sub_filter additions

add_header Content-Security-Policy "${csp}"
sub_filter_once off;
sub_filter ‘**CSP_NONCE**’ $request_id;
try_files /path/to/index.html =404;

sub_filter_once off; causes nginx to replace more than one instance of the placeholder. This is vital since we need to both apply it to the script tag as well as set it to the variable. The other command replaces all instances of **CSP_NONCE** with the same request_id listed in the CSP policy

The __webpack_nonce__ hidden feature

__webpack_nonce__ is a magic, horribly documented feature of Webpack, that actually does a great job. Except it requires a lot of hacking to actually apply it.

It must be placed at the top of the entry file specified in your Webpack set up (usually index.js). The very first line should look like:

__webpack_nonce__ = window.nonce

This specific recipe magically turns on a feature set in Webpack that applies nonces to all scripts loaded in runtime. It actually works quite well. Placing this variable anywhere else will cause it not to work. Line 1 of index.js!

Success! Just kidding. Now let’s modify webpack

Why doesn’t it work? Well if you apply a nonce-based CSP policy, the automatically generated script that actually loads the entry file can never be run because… you guessed it.. it’s not nonced. Webpack only applies the nonces *after* the entry file was loaded and has no way of applying it to the script that loads the entry file. But don’t worry, we have a solution for that.

At this point you should be serving an ironclad CSP3 policy with Nginx, creating a fresh, random nonce on every page load that gets applied to your index.html file. If you look at the network page or source code in the devtools of your browser, you will notice the index.html page has replaced the **CSP_NONCE** with the same nonce supplied in the CSP header. Excellent! You also have access to the nonce anywhere in your app via window.nonce. This is not a security concern because the attack vector would require the hacker to know the nonce before it is served.

So at this point, you might ask why are you looking at a white screen?

As I mentioned before, __webpack_nonce__ was only a partially complete implementation, probably why Slack and Google attempted to create their own solutions. Since __webpack_nonce__ can only be set in the entry file and not the index.html file, how can the entry file ever be loaded unless the nonce is applied to the outer script? Even more complicated if you use bundle splitting and/or chunks. Unfortunately the html-webpack-plugin does not have functionality for this so we are stuck.

Custom Plugin

In your webpack.config.js file we need to create a new custom plugin that takes a hook from the html-webpack-plugin and injects the **CSP_NONCE** placeholder to every script tag. Here comes the magic

var HtmlWebpackPlugin = require("html-webpack-plugin")class NoncePlaceholder {
apply(compiler) {
compiler.hooks.thisCompilation.tap("NoncePlaceholder", (compilation) => {HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync(
"NoncePlaceholder",
(data, cb) => {
const { headTags } = data
headTags.forEach((x) => {
x.attributes.nonce = "**CSP_NONCE**"
})
cb(null, data)
}
)
})
}
}var html = new HtmlWebpackPlugin({
title: "title",
template: "index.ejs",
filename: "index.html",
})const config = {
...
...
plugins: [html, new NoncePlaceholder()]
}

This NoncePlaceholder custom plugin will now inject a nonce=”**CSP_NONCE**” to every script in the index.html file allowing it to be covered by the nginx sub_filter and converted to the allowable nonce

What about third party scripts?

Since the nonce is present in window.nonce, you can apply that nonce to any script in your app. For example, for Google Tag Manager you might have

googleTagManager.setAttribute(“src”,“https://www.googletagmanager.com/gtag/js?id=" + id)googleTagManager.setAttribute(“nonce”, window.nonce)

You will need to apply the window.nonce as a nonce attribute to any imported script or tracker.

Other directives

As you apply nonces to allow third party scripts to run, you will notice a large number of CSP errors. For example Google Analytics requires whitelist entries in img-src and connect-src. These are not covered by strict-dynamic in CSP3. Until the next draft comes out from W3C, we still need to white list all other directives. For google you can follow their guide on what needs to be white listed. For most, they lack documentation entirely, and you will just need to test the functionality to see what needs to be whitelisted. Which is one of the reasons why a report-uri is so important.

But don’t whitelist everything you see an error for! Not everything is necessary for functionality and especially services from Google, or Meta will constantly try to invade your website with trackers for their own purpose. Your CSP headers will protect your users from that, only whitelist the minimum domains you can to achieve the functionality you want.

Final Thoughts

Why was this so complicated? Why can’t __webpack_nonce__ be better documented? Why can’t it be applied in the index.html file so that the entry file can be loaded instead of failing on itself? Why do most websites have inadequate CSP policies? Why do the webpack plugins created by industry leaders lead people to create insecure policies? Why doesn’t html-webpack-plugin allow us to set a nonce attribute? There are a lot of open source opportunities here. But after spending over a week on something that should have taken a few hours, I hope this blog post can help get people on the right track and implement a solid CSP3 policy