Adam Johnson

Home | Blog | Training | Projects | Colophon | Contact

Converting my CloudFront Lambda@Edge Function from JavaScript to Python

2020-02-13

I previously blogged about how I configured my CloudFront hosted website to score A+ on securityheaders.com. I worked around CloudFront’s lack of an “add headers” feature by adding a Lambda@Edge function in JavaScript.

In August last year, AWS announced Lambda@Edge support for Python 3.7. I write a lot more Python than JavaScript, so I thought it would be a good idea to convert my simple function from JavaScript to Python. Yes, despite knowing the painful slowness of CloudFront updates.

I use CloudFormation to configure my site’s resources. Because this Lambda function is small, I inline its source code in the template.

My old JavaScript Lambda function looked like this:

LambdaFunction:
  Type: AWS::Lambda::Function
  Properties:
    Code:
      # If updating the code DON'T FORGET TO UPDATE THE FUNCTION VERSION TOO
      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.gravatar.com; script-src 'self'; 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'
            }];

            headers['feature-policy'] = [{
              key: 'Feature-Policy',
                value: "accelerometer 'none'; ambient-light-sensor 'none'; autoplay 'none'; camera 'none'; encrypted-media 'none'; focus-without-user-activation 'none'; fullscreen 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; picture-in-picture 'none'; speaker 'none'; sync-xhr 'none'; usb 'none'; vr 'none'"
            }];

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

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

The magic DON'T FORGET TO UPDATE THE FUNCTION VERSION TOO comment came from hard-to-debug missed updates. It’s unfortunately not possible to configure CloudFront to always use the latest version of a Lambda function. Instead we need to create a new AWS::Lambda::Version each time, and not delete the old one with DeletionPolicy: Retain.

(More on this in my previous post.)

First, I added a new Python version of the Lambda function to my stack. I based it on the Overriding a Response Header Example in the CloudFront documentation and my existing JavaScript code:

PythonLambdaFunction:
  Type: AWS::Lambda::Function
  Properties:
    Code:
      # If updating the code DON'T FORGET TO UPDATE THE FUNCTION VERSION TOO
      ZipFile: |
        def lambda_handler(event, context):
            response = event["Records"][0]["cf"]["response"]
            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.gravatar.com; script-src 'self'; 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"}]
            headers["feature-policy"] = [
                {
                    "key": "Feature-Policy",
                    "value": "accelerometer 'none'; ambient-light-sensor 'none'; autoplay 'none'; camera 'none'; encrypted-media 'none'; focus-without-user-activation 'none'; fullscreen 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; picture-in-picture 'none'; speaker 'none'; sync-xhr 'none'; usb  'none'; vr 'none'"
                }
            ]

            return response

    Handler: index.lambda_handler
    MemorySize: 128
    Role: !GetAtt LambdaIAMRole.Arn
    Runtime: python3.7
    Timeout: 1

PythonLambdaFunctionVersion6:
  Type: AWS::Lambda::Version
  DeletionPolicy: Retain
  Properties:
    FunctionName: !GetAtt PythonLambdaFunction.Arn

I’m not sure if I needed to make it a new resource. It seems Lambda functions can change runtime with no interruption. But, it gave me an easy rollback plan as it kept the JavaScript function and versions live.

The data formats are the same between the two languages, so the code is essentially a transliteration. Python comes in at slightly fewer lines of code, but this is due to basic language differences: no need for braces or the 'use strict' incantation.

Second, I updated my CloudFront distribution to use the new function and deployed:

CloudfrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
    # ...
         LambdaFunctionAssociations:
         - EventType: viewer-response
-          LambdaFunctionARN: !Ref LambdaFunctionVersion11
+          LambdaFunctionARN: !Ref PythonLambdaFunctionVersion6

I thought this code was “simple enough” that there couldn’t be any bugs, given it was valid Python syntax.

Woops.

As you can see from the name in the template, it took me six iterations to get the code correct. As CloudFront takes 30 painful minutes to update its configuration, this took some time. I ended up putting on a film to wait them out!

(The 2004 Van Helsing if you want to know!)

My site was down while I had the bad versions of my code in place.

One useful thing I only checked for after a couple iterations was the “Configure test event” button on the Lambda console. It turns out it comes with many CloudFront examples. One was exactly my use case, and if I’d used it from the start I would have finished in half the time.

If only I’d read the CloudFront manual section Testing and debugging.

If I were to try this again, I would keep the code in a separate file and test it with pytest. I’d start with the test event from the Lambda console and change it for different scenarios. I think I would have caught most of my mistakes with such unit testing. I’ve done similar before for boto3 based code.

Third and finally, I removed the old JavaScript Lambda function and version. This was a straightforward deletion and redeploy.

Since the function was no longer in use it luckily didn’t another CloudFront update.

Fin

This was a slow exercise. CloudFront is getting more features, and I found debugging my Lambda@Edge function a bit less painful with the function metrics. But the slow iteration times still make it painful to change regularly.

Anyway, I hope this helps you work with Python on your Lambda@Edge functions,

—Adam


Are your Django project's tests slow? Read Speed Up Your Django Tests now!


Subscribe via RSS, Twitter, or email:

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

Related posts:

Tags: aws, cloudformation