Scoring A+ for Security Headers on My Cloudfront-Hosted Static Website

Secure like this castle

On Saturday, I posted my guide on Scoring A+ for Security Headers in Django, following my talk at DjangoCon Europe. I thought it would be a good idea to step up and make my own site score A+, rather than a dismal F! My site isn’t built in Django, but as a Jekyll static site. It’s hosted on AWS S3 and CloudFront.

Unfortunately, CloudFront isn’t so fully featured as other CDN’s and lacks a simple “add headers” configuration. However, its Lambda@Edge feature allows running JavaScript on responses. With this, we can add headers.

I found Tom Cook’s guide on Medium, which got me most of the way. Rather than using the AWS web console, I deploy my site with AWS’ Infrastructure-as-Code tool CloudFormation. Thus I needed to translate the guide into some CloudFormation template changes.

First I added an IAM role for my Lambda function and gave it permission to write logs:

LambdaIAMRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Version: 2012-10-17
      Statement:
      - Effect: Allow
        Principal:
          Service:
          - edgelambda.amazonaws.com
          - lambda.amazonaws.com
        Action: sts:AssumeRole

LambdaIAMRoleAllowLogging:
  Type: AWS::IAM::Policy
  Properties:
    Roles:
    - !Ref LambdaIAMRole
    PolicyName: AllowLogging
    PolicyDocument:
      Version: 2012-10-17
      Statement:
      - Effect: Allow
        Action:
        - logs:CreateLogGroup
        - logs:CreateLogStream
        - logs:PutLogEvents
        Resource:
        - !Sub arn:aws:logs:*:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunction}
        - !Sub arn:aws:logs:*:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunction}:*

Then I created the function, including the code inline in my template so I don’t have to upload it to an S3 bucket. Thanks to Tom Cook for the code, which I tweaked for my headers. I rolled out the headers iteratively, but the final version looks like:

LambdaFunction:
  Type: AWS::Lambda::Function
  Properties:
    Code:
      ZipFile: |
        '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'; font-src https://fonts.gstatic.com; img-src 'self' https://www.google-analytics.com https://www.gravatar.com; script-src 'self' https://www.google-analytics.com/analytics.js https://www.googletagmanager.com/gtag/js; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/"
            }];

            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'
            }];

            callback(null, response);
        };
    Handler: index.handler
    MemorySize: 128
    Role: !GetAtt LambdaIAMRole.Arn
    Runtime: nodejs8.10
    Timeout: 1

After this I had to tackle the complexity of Lambda versioning. CloudFront’s Lambda@Edge configuration requires a specific version of the Lambda function. This is a bit hard to configure with CloudFormation.

With the default behaviour updating to create a new version would mean deleting the old one. The easiest fix to this is to create the version with CloudFormation’s DeletionPolicy: Retain. This tells CloudFormation to retain old instances of the resource instead of deleting them.

Another downside is that if I deploy a new version of the code, I need to rename the version resource in the template to creates a new one.

So the version looks like this in my template (note it’s my eigth iteration here, so I suffixed it ‘8’):

LambdaFunctionVersion8:
  Type: AWS::Lambda::Version
  DeletionPolicy: Retain
  Properties:
    FunctionName: !GetAtt LambdaFunction.Arn

Then I needed to point my CloudFront distribution to execute the Lambda function version. There are multiple events one can hook into, here I needed the ‘viewer response’ event.

Configuring CloudFront with CloudFormation is my least favourite time so use AWS. It’s hard to get right first time and updating is super slow so testing can take hours. (I wrote most of this blog post in the time it took to update and iterate to A+.)

I added function in the LambdaFunctionAssociations key under the DefaultCacheBehavior. My full CloudFront configuration looks like this :

CloudfrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Aliases:
      - adamj.eu
      - www.adamj.eu
      Comment: adamj.eu
      DefaultCacheBehavior:
        AllowedMethods:
        - HEAD
        - GET
        Compress: true
        DefaultTTL: 60
        ForwardedValues:
          QueryString: false
        LambdaFunctionAssociations:
        - EventType: viewer-response
          LambdaFunctionARN: !Ref LambdaFunctionVersion8
        MaxTTL: 31536000
        MinTTL: 0
        TargetOriginId: S3-adamj-eu
        ViewerProtocolPolicy: redirect-to-https
    # ...

But now I have my A+ rating!

A+ rating for adamj.eu

(No, I didn’t bother adding Feature-Policy, since it’s still experimental and I haven’t got many scripts.)

Hope this helps you configure your site,

—Adam


If your Django project’s long test runs bore you, I wrote a book that can help.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: , , ,