CSP nonces the easy way with Cloudflare Workers

Everybody knows I'm a rather large fan of CSP and an even bigger fan of CSP reporting, but CSP can be hard. Part of my personal mission has been to make that easier and a lot of the tools and content I create are focused around that. To that end, here's how we're currently switching over to CSP nonces instead of host-based whitelists.



Content Security Policy

If you aren't familiar with CSP you can read my introduction blog post, my cheat sheet or any of the 35 posts tagged with CSP on my blog! The TLDR is that you can control what content loads on your site with a fairly simple HTTP response header called Content-Security-Policy that contains your policy. If you want to control where script loads from, you'd put script-src cdnjs.com example.com in the header and then the browser can only load scripts from those 2 locations. Simple! To get even more powerful, you can enable CSP reporting with a service like Report URI and if the browser tries to load script from a location not in your whitelist, not only will it block it, it will even send you a report to tell you about! You simply add another section to your policy like report-uri https://report-uri.com/example-report and the browser will send the report there. These are the basics of CSP and all is good. Well, except it can be hard...



Host-based whitelists are hard to generate and maintain

Generating a host-based whitelist can be quite hard and that kind of indicates the problem. Could you sit down and write out a list of all 3rd party locations your site loads script from? Probably not! There are many ways to get started on this process but to make that as easy as possible we created the CSP Wizard. Let Report URI work with the browser to do most of the hard work and all you need to do is polish the list at the end. There's even documentation to help you get started, but the list still needs to be maintained. New sources added, old ones removed, it's an ongoing process.

On top of that, host-based lists can be quite open too. Let's say you whitelist cdnjs.com for loading JS. Do you know how much JS is actually on there?! Any of it can be loaded and that might include some bad JS too in the form of old/vulnerable versions of libraries and other stuff you don't want loading. Yes, you could get more specific with a path in the policy, perhaps cdnjs.com/my-library/1.3.2/, but that's even harder to maintain! There's an easier way to do this, but it is harder to implement for most sites and that applies to us too.


CSP nonces to the rescue!

We talked about the problems with a host-based whitelist for CSP above and the ultimate solution is called CSP nonces. Rather than maintain a whitelist of domains, you can authorise only specific scripts on your website to be allowed to run. It looks like this.


Content-Security-Policy: script-src 'nonce-abcd1234'

...

<head>
  <script src="cdnjs.com/my-library.js" nonce="abcd1234">


That's a basic example but it illustrates exactly how CSP nonces work. You need to generate the nonce and inject it into the header, and also inject it into any script tag on the page you want to run. The idea here being if an attacker injects their hostile script tag into the page, they won't know the correct nonce to put into the nonce attribute and the script won't be executed. Of course, this means the nonce can't be static and must be generated and injected on a per-page basis (hence the name "Number ONCE"). Here lies the problem, and here comes the solution.


Injecting nonces with a Cloudflare Worker

I've written a few blog posts about Cloudflare Workers already and that's because they're really powerful and easy to use. They're doing a lot of important things in my projects like helping you read this blog or processing hundreds of millions of requests a day on Report URI. We maintain state in them, deprecate legacy TLS protocols with them and even use them to improve security on other sites. Now, we're going to use them to improve the security on our site by improving our CSP!



In short, before we dig into the technical details, we're going to use the Cloudflare Worker to generate our CSP nonce and send it to the origin with the request. Our origin will then add the nonce attribute to all script tags and respond to the Worker. Finally, the worker will build the CSP header with the nonce and inject it into the response sent to the client! Hopefully that brief explanation will help you follow along with what's happening in the following code.

The first thing the Worker needs to do is generate a nonce. This is easy enough to do and we need to generate it and store it in a variable for later use.


let cspNonce = btoa(crypto.getRandomValues(new Uint32Array(2)))


Now we have the nonce ready, our Worker can pass it to the origin with the original request. Here we're creating a request header called CSP-NONCE and sending the nonce to the origin in that header.


let newReq = new Request(req)
newReq.headers.set('CSP-NONCE', cspNonce)
let response = await fetch(newReq)


Once that request hits our origin, our application servers need to inject the nonce into all script tags. We make the nonce available to all of our views and it simply needs injecting into the nonce attribute on script tags (and style tags if you want).


<script src="https://cdn.report-uri.com/js/layout-account.min.js" nonce="<?= html_escape($cspNonce); ?>"></script>
<script src="https://cdn.report-uri.com/js/jquery.nav.min.js" nonce="<?= html_escape($cspNonce); ?>"></script>
<script src="https://cdn.report-uri.com/js/setup.min.js?v=1.0.3" nonce="<?= html_escape($cspNonce); ?>"></script>
<script src="https://cdn.report-uri.com/js/ga.min.js" nonce="<?= html_escape($cspNonce); ?>"></script>


Now that we've set the nonce on all of our scripts tags, we will build a response as normal and send it back to the Worker. From the Worker, all we need to do is build the CSP header and inject it into the response headers that came from the origin. We build our CSP from an object in the Worker and inject the nonce.


const cspConfig = {
  "default-src": [
    "'self'",
  ],
  "script-src": [
    "cdn.report-uri.com",
    "api.stripe.com",
    "js.stripe.com",
    "www.google-analytics.com",
    "www.gstatic.com",
    "www.google.com",
    "'nonce'",
  ],
  ...
}

function buildCspHeader(cspConfig, nonce = null) {
  let directives = []

  Object.keys(cspConfig).forEach(function(directive) {
    let values = Array.from(cspConfig[directive])
    values.forEach(function(value, key) {
      if (nonce && value === "'nonce'") {
        values[key] = "'nonce-" + nonce + "'"
      } else if (nonce === null && value === "'nonce'") {
        values.splice(key, 1)
      }
    })
    if (values.length === 0) {
      directives.push(directive)
    } else {
      directives.push(directive + ' ' + values.join(' '))
    }
  })

  return directives.join('; ')
}


The returned string is a valid CSP header with a nonce and can be injected into the response.


let newResponseHeaders = response.headers
newResponseHeaders.set('Content-Security-Policy-Report-Only', buildCspHeader(cspConfig, cspNonce))
let init = {
  headers: newResponseHeaders,
  status: response.status,
  statusText: response.statusText
  }

return new Response(response.body, init)


That's it! You now have a dynamically generated CSP nonce injected into each page.


Testing the new nonce-based header

Of course, like any change, this should be tested properly before being deployed and the same is true for CSP. You can see above the header I injected into the page was a Content-Security-Policy-Report-Only meaning the browser will not enforce the policy, but it will send me reports if things go wrong in the page. This is a safe way to test your policy and you can see the header being set on our live site right now if you scan it with Security Headers.



That looks good, the nonce is in the header and everything looks as you'd expect. I then jumped over to the Element Inspector in Chrome dev tools to check out the nonce there and...



Damn, no nonce! I pondered for a few minutes and with absolutely no help from Michal I quickly figured this out. The dev tools doesn't show nonces to help with leakage, so it is there you just can't see it! If you go to the view-source page then you can see the nonce in the page lines up with the nonce in the header perfectly.



That's really not too bad to get going and you don't even need a paid account on Cloudflare to do it. Their free tier account is more than good enough and the first 100,000 Worker executions per day are free. After the free tier, it's $5/mo for each 10,000,000 Worker executions but my blog can stretch the free tier for now and if you ever hit the $5/mo tier, I'm sure they will go a long way on a normal site.


Security Considerations

There's a few things worth pointing out that you should take into consideration:

If you're using another provider or mechanism to generate the nonce and pass it to the origin, make sure you scrub any existing CSP-NONCE header being sent from the client. Our implementation above takes care of this for us and if the client sets the CSP-NONCE header in a request, it will be overwritten.

When passing the nonce to your origin, the origin should treat the nonce as potentially hostile. We rely on the Worker to scrub any request headers from the client and we generate our own nonce in the Worker, but there's no harm in being careful. You can see we still output encode the nonce in our pages.

Our Worker remembers the nonce we send to the origin and does not utilise any nonce information sent back from the origin. If an attacker can control your application in some way, they may be able to set and send a header back to the Worker. By having the Worker generate and store the nonce between request and response, we isolate the application from having control of it.