James Ridgway - Software Engineer

Applying Security Headers on Serverless Infrastructure with AWS Lambda@Edge

Using AWS CloudFront and AWS Lambda@Edge to set response headers at the edge of the AWS CloudFront distribution network.

Security headers control how a browser behaves when accessing a website. Setting a security header is a straightforward process for any application hosted using a conventional web server such as Nginx, Apache, etc.

But how do you set security headers if you don't have a conventional web server? What if your website is serverless running on AWS Lambda, S3 and CloudFront as is the case for my custom short url generator project.

The current landscape of serverless computing means that CloudFront (and other major CDNs) allow you to execute code (a Lambda function) at the edge of the CDN network. Through this process we can modify and attach the necessary security headers.

Introducing Lambda@Edge

CloudFront has many edge nodes around the world. As an end-user (viewer) whenever you access content that is distributed via CloudFront your request is directed to the edge node that is closest to your computer. If the content has already been cached by the edge node the node can return the content directly. However, if the content isn't in the cache on the edge node, the node will need to request the content from the origin (the data source behind the CDN).

Lambda@Edge allows you to use Lambda functions to change CloudFront requests and responses at the following points:

  • After CloudFront receives a request from a viewer (viewer request)
  • Before CloudFront forwards the request to the origin (origin request)
  • After CloudFront receives the response from the origin (origin response)
  • Before CloudFront forwards the response to the viewer (viewer response)

CloudFront Lambda Functions

CloudFront can only integrate with Lambda functions created in the North Virgina region.

Origin Response Lambda

We can use a Lambda to modify the origin response to attach our headers. Applying the headers at the origin response means that the headers can be cached by CloudFront and our Lambda function need only run on a CloudFront miss.

CloudFront Lambda Functions - applying a lambda to origin response

CloudFront currently only supports Node.js runtimes. Our Lambda function will look as follows:

'use strict';
exports.handler = (event, context, callback) => {
    const response = event.Records[0].cf.response;
    const headers = response.headers;

    headers["strict-transport-security"] = [{key: "Strict-Transport-Security", value: "max-age=31536000; includeSubdomains; preload"}]; 
    headers["content-security-policy"] = [{key: "Content-Security-Policy", value: "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src 'self' fonts.googleapis.com fonts.gstatic.com; object-src 'none'"}]; 
    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"}]; 
    headers["feature-policy"] = [{key: "feature-policy", value: "accelerometer 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; payment 'none'; usb 'none'"}];

    callback(null, response);
};  

Once you have created the Lambda function you will need to produce a published version via the "Publish new version" acttion in the "Actions" menu.

AWS Lambda - publish new version

Publishing a lambda function will give you an ARN with a version number appended to the end of the ARN:

arn:aws:lambda:us-east-1:123:function:apply_security_headers:1

This can then be associated with the CloudFront distribution's S3 behaviour:

Associate AWS Lambda function by editing distribution behaviour

Configuring via Terraform

If you wanted to configure an edge Lambda via terraform, the terraform config would look like this:

data "archive_file" "apply_security_headers" {
  type        = "zip"
  source_dir  = "lambda_functions/apply_security_headers"
  output_path = "lambda_functions/apply_security_headers.zip"
}

resource "aws_lambda_function" "apply_security_headers" {
  provider         = "aws.cloudfront_acm"
  filename         = "lambda_functions/apply_security_headers.zip"
  function_name    = "apply_security_headers"
  role             = "${aws_iam_role.short_url_lambda_iam.arn}"
  handler          = "lambda_function.handler"
  source_code_hash = "${data.archive_file.create_shorturl.output_base64sha256}"
  runtime          = "nodejs8.10"
  publish          = true
}

resource "aws_lambda_permission" "short_url_lambda_permssion_apply_security_headers_edgelambda" {
  provider      = "aws.cloudfront_acm"
  statement_id  = "AllowExecutionFromCloudFront"
  action        = "lambda:GetFunction"
  function_name = "${aws_lambda_function.apply_security_headers.arn}"
  principal     = "edgelambda.amazonaws.com"
}
resource "aws_lambda_permission" "short_url_lambda_permssion_apply_security_headers_lambda" {
  provider      = "aws.cloudfront_acm"
  statement_id  = "AllowExecutionFromCloudFront2"
  action        = "lambda:GetFunction"
  function_name = "${aws_lambda_function.apply_security_headers.arn}"
  principal     = "lambda.amazonaws.com"
}

resource "aws_iam_role" "short_url_lambda_iam" {
  name = "short_url_lambda_iam"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": [
          "lambda.amazonaws.com",
          "edgelambda.amazonaws.com"
        ]
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "short_url_lambda_policy" {
  name = "short_url_lambda_policy"
  role = "${aws_iam_role.short_url_lambda_iam.id}"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stm1",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Sid": "Stm2",
      "Effect": "Allow",
      "Action": [
        "lambda:GetFunction"
      ],
      "Resource": "${aws_lambda_function.apply_security_headers.arn}:*"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "short_url_lambda_policy_s3_policy_attachment" {
  role       = "${aws_iam_role.short_url_lambda_iam.name}"
  policy_arn = "${aws_iam_policy.short_url_s3_policy.arn}"
}

resource "aws_cloudfront_distribution" "short_urls_cloudfront" {
  depends_on = ["aws_lambda_function.apply_security_headers"]

  # ....

  default_cache_behavior {

    # ...

    lambda_function_association {
      event_type = "origin-response"
      lambda_arn = "${aws_lambda_function.apply_security_headers.qualified_arn}"
    }

    # ...

  }

  # ...

}

You can see the full version of the above example by looking at short_urls.tf in my aws-lambda-short-url GitHub project.

Checking Your Headers

As you implement and test the Lambda function don't forget that you may need to invalidate cached responses from the origin.

securityheaders.com is a project by security research Scott Helme that allows you to easily check the security headers on your website.

Before implementing the Lambda function my short url project received the following security report summary:

securityheaders.com Grade F assessment of jmsr.io

Now with the complete implementation of the Lambda edge function I get an A+ security rating:

securityheaders.com Grade A+ assessment of jmsr.io

Execution @ The Edge

This is a versatile approach for applying security headers to any servless content. It doesn't matter if your serverless content is coming from a Lamba, API Gateway, S3 etc. provided the content is distributed via CloudFront you can rely on Lambda@Edge functions to apply security headers.

Read More