Setup HTTP Security headers in a S3 hosted website

In a previous blog post I described how to host a Angular application using S3 and CloudFront. The combination of S3 and CloudFront is required to avoid 404 (page not found) errors when a user tries to access a dynamic route, defined in Angular. By itself, S3 works as a static website host (you can think of it as a directory), so it's only prepared to serve the files it is hosting. CloudFront can be configurated to intercept error responses (like 404 file not found) from S3 and return the root of your Angular app (index.html), then it will be Angular's responsibility to navigate and display the correct page for the defined route. This technique can be applied to other SPA frameworks, but for the sake of simplicity I will focus this blog post around Angular.

The described strategy works perfectly fine, but it has some limitations, like the inability to configure custom HTTP headers. This is problematic if you want to configure HTTP Security headers. If you are not familiar with HTTP Security headers, I strongly recommend a quick read of this article, or for a more in depth look, checkout out this PluralSight course. Additional articles about HTTP security headers can be found at the bottom of this post. Bottom line, the desired intention is to be able to configure custom HTTP headers.

To get around this problem we could spin-up a EC2 instance, install a web server (i.e. nginx) and configure it ourselves. Totally valid solution, but the implication is a new virtual machine that you will need to manage.

The solution we are going to explore today is using CloudFront in combination with a Lambda@Edge function. CloudFront provides the capability to associate a Lambda function that will act as a HTTP interceptor. Conceptually this technique can be interpreted as a web application middleware, by applied to a cloud native application. In summary, responses from S3 will be intercepted by a Lambda function and modified to include the HTTP headers defined by us. The following diagram describes the event flow of this solution.

cloudfront events that trigger lambda functions

Before proceeding please be aware of the current AWS Lambda@Edge requirements. Most notably, by of time of this writing Lambda@Edge can be only associated functions creating in US East (N. Virginia) region. Keep an eye on the provided link for updates. Also, double check that none of the blacklisted or read-only headers are being modified.

This article it's a follow-up on two of my previous articles, therefor it's assumed:

Now let's create our Lambda function. Here a list of required settings followed by the respective screenshot:

  • set AWS region to US East (N. Virginia)
  • set runtime to Node.js 6.10 (works with 8.10 too)
  • ensure this Lambda function have read access to S3 "S3 object read-only permissions"
  • have "Basic Lambda Edge permissions"

create lambda screen

Regarding the code, there are a few things you need to be aware of. At the moment, environment variables are not supported. If you use environment variables, the following error will show up when you try to associate the function CloudFront: com.amazonaws.services.cloudfront.model.InvalidLambdaFunctionAssociationException: The function cannot have environment variables. Function: arn:aws:lambda:us-east-1:999999999999:function:s3_response_interceptor_for_spa:1 (Service: AmazonCloudFront; Status Code: 400; Error Code: InvalidLambdaFunctionAssociation; Request ID: e9b7e605-5d45-11e8-940a-273897b66c49)

Additionally, the function needs to be published before it can be associated with a CloudFront event. Unfortunately, this makes testing a bit painful, since you would need publish every change to be able to test it. Fortunately, we can leverage Lambda test configuration to simulate a CloudFront origin response event. But let's testing for later. For now, let's check the actual Lambda function code. There are some inline comments to help understand the rationale behind it.

'use strict';

// function settings (since environment variables are not supported)
const s3BucketName = 'pwa.johnlouros.com';
const s3IndexFile = 'index.html';

const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = (event, context, callback) => {
    // modify response by intercepting CloudFront Origin Response event
    let response = event.Records[0].cf.response;
    const headers = response.headers;

    // set HTTP Security headers or other custom headers (check the AWS docs for limitations)
    headers['content-security-policy'] = [{
        key: 'Content-Security-Policy',
        value: "script-src 'self'"
    }];

    headers['x-content-type-options'] = [{
        key: 'X-Content-Type-Options',
        value: "nosniff"
    }];

    headers['x-frame-options'] = [{
        key: 'X-Frame-Options',
        value: "DENY"
    }];

    headers['x-xss-protection'] = [{
        key: 'X-XSS-Protection',
        value: "1; mode=block"
    }];

    headers['referrer-policy'] = [{
        key: 'Referrer-Policy',
        value: "same-origin"
    }];

    // handle 'Bad Request', 'Forbidden' or 'Not Found' responses
    if (response.status === '400' || response.status === '403' || response.status === '404') {

        // from S3 get the contents of 'index.html'
        s3.getObject({
            Bucket: s3BucketName,
            Key: s3IndexFile
        }, (err, data) => {
            if (err) {
                callback(err);
            } else {
                headers['content-type'] = [{
                    key: 'Content-Type',
                    value: "text/html"
                }];

                // prepare response message
                response = {
                    headers: headers,
                    body: data.Body.toString('utf-8'),
                    status: '200',
                    statusDescription: 'OK'
                }
                callback(null, response);
            }
        });
    } else {
        callback(null, response);
    }
};

We don't want to publish this version without testing. Let's create a test event to verify how this code behaves when S3 returns a 400 error. Screenshot and respective event JSON below:

configure Lambda test to mock CloudFront origin response event

{
  "Records": [
    {
      "cf": {
        "config": {
          "distributionId": "EXAMPLE"
        },
        "response": {
          "status": "400",
          "headers": {
            "last-modified": [
              {
                "value": "2016-11-25",
                "key": "Last-Modified"
              }
            ],
            "vary": [
              {
                "value": "*",
                "key": "Vary"
              }
            ],
            "x-amz-meta-last-modified": [
              {
                "value": "2016-01-01",
                "key": "X-Amz-Meta-Last-Modified"
              }
            ]
          },
          "statusDescription": "OK"
        }
      }
    }
  ]
}

Assuming everything was properly defined, the test execution result should return a 200 status code and the body of your 'index.html'. Now let's publish this version.

publish a new version of this Lambda function

To be able to associate a AWS Lambda@Edge function with a CloudFront event, get a Lambda ARN from the published version.

view lambda function published

Then navigate to a CloudFront distribution, select 'Behaviors' tab and edit the main behavior.

cloudfront behaviors page

Create a new Lambda Function Association, mapping 'Origin Response' to your published function version ARN.

edit CloudFront behaviors

Finally, on a previous blog post is was described how CloudFront 'Error Pages' could be used to get around error bubbled up from S3. Please make sure those custom error response mappings are deleted. From now on, the Lambda function will handle all you error responses from S3. To avoid any undesired behavior ensure CloudFront doesn't handle the error responses.

remove error pages settings from CloudFront

References