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

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!

(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.
One summary email a week, no spam, I pinky promise.
Related posts:
- How to Score A+ for Security Headers on Your Django Website
- Feature-Policy updates - now required for an A+ on SecurityHeaders.com
- Django’s Test Case Classes and a Three Times Speed-Up
Tags: aws, cloudfront, cloudformation, jekyll