The nVisium Blog

Lambda@Edge, CloudFront, and Custom Response Headers

Back in early 2017, AWS released a preview of the new Lambda@Edge functionality. This allowed Lambda triggers to be set on CloudFront and Origin sources requests and responses. Leveraging this functionality, it is now possible to set custom headers on resources cached via CloudFront.

Lambda@Edge

The ability to run Lambda requests against CloudFront hosted resources is a powerful tool for any organization which relies heavily on CloudFront caching. One could leverage this to create an authorization middleware between protected resources by redirecting users to different URLs depending on HTTP request cookie values, or append analytic JavaScript to pages depending on the requesting user's geographical location. However, today I wanted to touch on the reason nVisium has begun taking advantage of this functionality: adding security-centric response headers.

CloudFront has supported some security headers in one form or another. For example, CORS could be implemented by enabling it on the S3 bucket (or whatever Origin you use) and configuring CloudFront to allow the OPTIONS HTTP verb and to forward the appropriate CORS HTTP headers. However, other headers, such as X-XSS-Protection or X-Content-Type-Options, could not be configured in responses unless the origin was configured to send them. This created limitations for CloudFront+S3 architectures or other Origins with less-than-flexible response header configuration options.

Configuring Our Setup

The Lambda@Edge documentation covers most of what you need to know about getting started, but this will be a soup-to-nuts writeup. For this example, I'll be enabling the HSTS and content sniffing headers on all CloudFront responses. If you plan to follow along with this writeup, ensure you have an S3 bucket created and set as the Origin of a CloudFront distribution. With this done, lets create a new Lambda function.

Lambda now has a new trigger - CloudFront. When creating a Lambda function from scratch with a CloudFront trigger, you'll be asked to provide the CloudFront distribution ID and the type of CloudFront event to trigger on: Origin Request/Response or Viewer Request/Response. The event names are fairly indicative of when an event would fire, but here's an image from the AWS documentation detailing it:

alt text
CloudFront trigger event types

For adding custom response headers, we'll be setting the event to Viewer Response. Set the function to execute with a sufficiently privileged role (see below) and reduce the timeout to one second within the advanced settings. Currently, Lambda@Edge functions must have a max timeout of one second. For logging purposes, I leveraged the following policy for my function:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:us-east-1:014890146463:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:014890146463:log-group:/aws/lambda/SecureHeadersLambdaEdge:*"
            ]
        }
    ]
}

The role must also have a trust relationship with Lambda@Edge, not just Lambda. This is as simple as modifying the trust policy as follows:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "lambda.amazonaws.com",
          "edgelambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

After creation, a success message will be displayed with the function ARN at the top of the page. Of particular note is the :<version_num> that is appended to the ARN. (Note: The following screenshot is version 2 as I had initially forgot to reduce the default timeout.)

alt text
Successful creation of initial Lambda function

AWS will replicate the function across all regions at creation and any time a new version is published. The following diagram from the AWS documentation shows how this works:

alt text
Lambda function replication flow graph

The version number is important as it is required when specifying which script the CloudFront event triggers on. For example, my associated CloudFront distribution has a behavior with the arn:aws:lambda:us-east-1:014890146463:function:SecureHeadersLambdaEdge:2 Lambda ARN tied to the Viewer Response event. Any time you make changes to your Lambda@Edge script, you must publish a new one and manually update the CloudFront distribution behavior to leverage the new version.

Updating the CloudFront distribution can be done two ways. The first is to leverage the "triggers" functionality directly from the Lambda console page after publishing:

alt text
Adding a trigger to the latest Lambda@Edge function version

alt text
Configuring the Lambda@Edge trigger by associating it with our CloudFront distribution behavior

The second is to update the ARN associated within the CloudFront distribution behavior itself:

alt text
Updating the CloudFront distribution behavior to the correct Lambda version ARN

The Lambda Function Itself

Now, with all the setup out of the way, lets get into the Node.js code itself. This is actually the easiest part, as the request and response are made available to you via the event parameter. The following function will set both the HSTS and anti-content sniffing headers:

'use strict';

exports.handler = (event, context, callback) => {
    console.log('Adding additional headers to CloudFront response.');

    const response = event.Records[0].cf.response;
    response.headers['strict-transport-security'] = [{
        key: 'Strict-Transport-Security',
        value: 'max-age=86400',
    }];
    response.headers['x-content-type-options'] = [{
        key: 'X-Content-Type-Options',
        value: 'nosniff',
    }];

    callback(null, response);
};

The important thing to note here is that the response.headers attributes are named the same as the key value itself. For example, the following code snippet would fail:

'use strict';

exports.handler = (event, context, callback) => {
    const response = event.Records[0].cf.response;
    response.headers['hsts'] = [{
        key: 'Strict-Transport-Security',
        value: 'max-age=86400',
    }];

    callback(null, response);
};

No error would be thrown by the Lambda script itself, but an error would get returned to the user indicating that the function returned a bad response object.

alt text
Invalid response object error returned from CloudFront

Now simply save and then publish a new version via the Actions menu at the top. Go back to the CloudFront distributions behavior and udpate the Lambda ARN to reflect the latest version available. The update will take a few minutes to propagate, but after it's done, all resources served via that CloudFront distribution will now have the HSTS and content type headers set.

alt text
HSTS and anti-content sniffing headers successfully set!

Conclusion

And that's it! However, there are a few caveats to be aware of. First off, Lambda@Edge scripts are currently locked to only Node.js 6.10. Additionally, the CloudFront trigger is currently only available for Lambda scripts created within the us-east-1 region. It's also worth noting that logs will be generated within the region of the CloudFront location you are testing against. So myself, living out in the Pacific Northwest, would reference CloudWatch logs within the us-west-2 region when debugging.

Lastly, be aware that your Lambda function list is going to become littered with a new replicated version every time it is published. At the time of writing, deleting the $LATEST function is possible, but the old version replicas remain and cannot be individually deleted. This can quickly lead to a very messy console.