Nonce-Based CSP with AWS CloudFront

Using a strict CSP with a CDN can be challenging

Kris Wong
Level Up Coding

--

Photo by Dayne Topkin on Unsplash

Background

Before we dive into the depths of strict Content Security Policy, let’s first briefly review the topic of security headers. Modern browsers support many different security headers that you can easily add to your website/web app. AWS CloudFront supports a number of these headers via Response headers policies, which you can easily associate with any behaviors you have. In fact, there’s even a managed policy named SecurityHeadersPolicy. The following is the policy we use at AnthemIQ for our index.html.

You can verify the security headers sent by your site using this nifty scanner.

Now, when it comes to strict CSP, I recommend that you start here to build a solid understanding of CSP and to see examples of a strict CSP. They also have a CSP evaluator.

The Firefox Problem

Assuming you have reviewed the site linked above, you now realize that there are two kinds of strict CSP with CSP 3. They are hash-based and nonce-based CSP. The nice thing about the hash-based CSP is that it doesn’t require injecting a random string into your index.html file every time it is served. And, in fact, Angular can even generate script hashes for you when it builds your application. You still need to get those hashes into the CSP header returned by your CDN, but that step gets a little easier vs. using a random nonce.

But… Firefox does not support hash-based CSP. Not only is it not supported, but since Firefox has partial CSP 3 support, it also doesn’t fall back to the insecure unsafe-inline either. Instead, it’s just completely broken. So, if you need Firefox support, then you only have one option — nonce-based CSP.

CloudFront Functions and Lambda@Edge

As a quick review, we know that CloudFront serves content from your configured origins via behaviors. I am assuming you are familiar with how this works. For many of us, we are serving static web content from S3 buckets. The problem boils down to serving this static content with a random string injected into it, which must change every time the content is served. Essentially, we are making our static content dynamic.

There are two primary mechanisms to do this — CloudFront functions and Lambde@Edge functions. I won’t dive into all the differences here, but suffice it to say that CloudFront functions are faster, while Lambda@Edge functions are much more capable. CloudFront functions don’t support what we need to do, so we’ll be using Lambda@Edge.

Lambda@Edge functions run a full Node.js environment, with network and disk support. The AWS SDK is included automatically, as it always is with Lambda functions. The main thing to note is that you must publish your function before you can use it with CloudFront. It does not support LATEST; you have to specify a version. That version of your function will be cached on various CloudFront nodes (hence the @Edge).

To complicate matters a little more, there are 4 different function associations that can be used:

  • Viewer request
  • Viewer response
  • Origin request
  • Origin response

Again, I won’t go into all the details here, but these options affect what gets cached and what properties on the request and response you have access to. The main points to understand are:

  • none of these options give the function access to the response body returned from the origin, which means we are going to have to make a get-object request from our function to S3
  • because of this, we should prevent CloudFront from making its own origin request
  • we also need to be sure CloudFront does not cache the response from our function

The association that best fits our requirements is Viewer request.

Creating Our Lambda@Edge Function

To start, we need to create a Lambda function with a Node.js 18.x runtime. Because AnthemIQ is a single-page application (SPA), you will notice the below function code only serves index.html. This is the only file that needs security headers attached to it. The following is the function code (note the strings that need to be replaced):

We are not using any 3rd party modules (except what’s already provided by AWS), so there’s no need for npm. It’s very straightforward:

  1. fetch the original index.html from S3, but only if the ETag has changed
  2. generate a nonce
  3. inject it onto the script tags in the index.html
  4. return the modified index as the response, along with the CSP header

You won’t be able to test your function until you have configured the IAM role properly. AWS will have created an execution role for your Lambda, so load up IAM, click on Roles, and search for your Lambda function name. Open the role and select the Trust relationships tab. You need to add edgelambda.amazonaws.com to the existing Service array. Now click on the Permission tab and edit your policy. This is the most open version of a policy that will work, but I recommend following the principle of least privilege:

{
"Statement": [
{
"Action": [
"logs:PutLogEvents",
"logs:CreateLogStream",
"logs:CreateLogGroup"
],
"Effect": "Allow",
"Resource": "*",
"Sid": ""
},
{
"Action": "s3:GetObject",
"Effect": "Allow",
"Resource": "*",
"Sid": ""
}
],
"Version": "2012-10-17"
}

Now you will be able to test, deploy, and publish your Lambda function, after which we’ll set up CloudFront to use it.

Configuring CloudFront

Our CloudFront configuration is fairly straightforward. We have one origin (the S3 bucket where our static web files live) and 3 behaviors. Using 3 behaviors allows our SPA to function properly (serving the index.html by default). Our path patterns are as follows:

  1. index.html
  2. *.*
  3. Default (*)

The index.html and default path patterns both run the above Lambda@Edge function, returning the index.html with the desired security headers. Ensure caching is disabled on these behaviors so the nonce gets updated every time the file is served. Be sure to select the appropriate Response headers policy. Under function associations, Viewer request, select Lambda@Edge and enter your function ARN, including the version number. Do not check the include body checkbox.

The *.* path pattern matches any routes that include a filename. If your app routing uses routes that include a “.” character, then you will need to get more specific with your path pattern (i.e., use actual file extensions). For this behavior, CloudFront will fetch the file from the origin normally. We use the CachingOptimized cache policy and no security headers in this case. This configuration allows CloudFront and the user’s browser to cache the files normally.

One thing to consider with this change is service worker, especially given its caching APIs. We do not use service worker at AnthemIQ. Every request for index.html fetches the most up-to-date version. We find this best meets our needs.

Summary

We covered a lot of ground in this post. We explored:

  • What are security headers and content security policy
  • What is a strict CSP
  • How Firefox prevents us from leveraging hash-based CSP
  • What are CloudFront and Lambda@Edge functions
  • How we can leverage Lambda@Edge functions to inject a random nonce onto our script tags and return a strict CSP header
  • How we configure CloudFront behaviors to serve our modified index.html file

There were a few topics that I did not go into great detail on in order to keep the post a reasonable length. I recommend that you read the linked resources to get a complete understanding of the topics covered.

As always, I hope that you found this post helpful!

Level Up Coding

Thanks for being a part of our community! Before you go:

🚀👉 Join the Level Up talent collective and find an amazing job

--

--