Networking & Content Delivery

Complying with city-level embargos using Amazon CloudFront

Introduction

You may run into occasions where, due to sanctions from governmental organizations like OFAC (Office of Foreign Assets Control), you need to implement granular city-level embargos for your websites. This blog will walk you through an approach to achieving this using Amazon CloudFront geolocation headers and Amazon CloudFront Functions. Note that geographical restrictions at a country level can be set up in the AWS service console using AWS WAF . This blog also has steps to create a simple CI/CD process that enables managing city level records and updating CloudFront Functions.

Overview of the solution

In order to implement granular controls, you will need to set up a scalable edge solution using Amazon CloudFront‘s serverless compute feature CloudFront Functions. Functions can manipulate the requests and responses that flow through CloudFront, perform basic authentication and authorization, generate HTTP responses at the edge, and more.

To see how this will work, let’s take a brief look at how traffic flows through CloudFront. Figure 1 below shows a typical request flow from end-user client. When a user request hits the nearest Edge location and the requested content is not in edge cache, edge location will retrieve the data from the Regional edge location. If the requested data is not present in Regional edge location, it is retrieved from the origin and cached in the Regional edge location as well as the edge location.

Figure 1: Customer CloudFront Edge request data flow

Next, let’s take a look at how traffic will flow through this new solution. As shown below in Figure 2, when a request hits the edge location, it results in two events at the edge i.e “Viewer Request (when CloudFront receives a request from a viewer)” or “Viewer Response (before CloudFront returns the response to the viewer)” which can be used to trigger CloudFront functions at Edge locations. You will see that the “Viewer Request” event is used in this solution since a request needs to be validated before it can be served by CloudFront. The function code will inspect the CloudFront request headers CloudFront-Viewer-Country, CloudFront-Viewer-Country-Region, and CloudFront-Viewer-City to determine the geo location of the request . It will then match the header with embargo policies to validate if the current request originated from any of the embargoed cities. If a match is found, the request is denied with HTTP Status 403. Otherwise, the request is forwarded to CloudFront.

 In Figure 2, you will see that viewer request will hit CloudFront Function which determines if request should get forwarded to CloudFront Edge cache or deny the request by returning 403 forbidden error.

 

Figure 2: CloudFront function handling city level embargo

Implementation Steps

Prerequisites

Make sure you complete the following prerequisites:

  1. To use CloudFront Functions, you need a CloudFront distribution. If you don’t have one, follow the steps in Getting started with a simple CloudFront distribution.
  2. For the geo location headers to be available to the CloudFront Function, create an Origin Request Policy with origin request settings containing the headers CloudFront-Viewer-Country, CloudFront-Viewer-Country-Region, and CloudFront-Viewer-City. For more information on how to create Origin Request Policy, see Creating origin request policies.

Once you have your Amazon CloudFront distribution setup, follow these steps:

  • Decide on the embargo policy structure that your CloudFront function will use

The policy can be a simple JSON object so that it can be easily stored (in a data store or file system) or manipulated as required. For this implementation, the following structure is used:

{
  "policy": {
    "country_cd": "US",
    "country_region_cd": "TX",
    "country_region_city": "Prosper"
  },
  "policy_id": "p-103"
}

country_cd: To be matched with header CloudFront-Viewer-Country.

country_region_cd: To be matched with header CloudFront-Viewer-Country-Region.

country_region_city: To be matched with header CloudFront-Viewer-City.

For more information about the CloudFront location headers please see Headers for determining viewer’s location

  • Create, publish and associate the CloudFront function

CloudFront Functions uses a JavaScript runtime environment that is compliant with ECMAScript (ES) version 5.1 and also supports some features of ES versions 6 through 9. For more information see JavaScript runtime features for CloudFront functions.

CloudFront Functions is a native feature of CloudFront, which means you can build, test, and deploy your code entirely within CloudFront. For information about how to create, publish, and associate a CloudFront function via AWS Console see Tutorial: Creating a simple function with CloudFront Functions.

For this implementation, you can use the following JavaScript code while creating the CloudFront function:

function handler(event) {

    var policies = {"items": [{"country_cd": "us", "country_region_cd": "tx", "country_region_city": "aubrey"}]};
    
    var request = event.request;
    var headers = request.headers;
    if (!headers['cloudfront-viewer-city']) {
        // "cloudfront-viewer-city" header is missing, skip the validation.'
        return request;
    }
    var requestCountry = headers['cloudfront-viewer-country'].value;
    var requestRegion = headers['cloudfront-viewer-country-region'].value;
    var requestCity = headers['cloudfront-viewer-city'].value;    
        var matched = policies.items.some(function(e) {
        return this[0].toLowerCase() == e.country_cd.toLowerCase() 
        && this[1].toLowerCase() == e.country_region_cd.toLowerCase()
        && this[2].toLowerCase() == e.country_region_city.toLowerCase()
        }, [requestCountry, requestRegion, requestCity]
    );
    
    if (matched) {
        var response = {
            statusCode: 403,
            statusDescription: 'Forbidden',
        }
        return response;
    }
    return request;
}

Keeping the CloudFront Function updated

The embargo policies can change based on compliance requirements or business needs. Therefore, the Security/Ops team will often need to modify the embargo rules. This can be easily achieved by storing the embargo policies in NoSQL database like Amazon DynamoDB and giving the managing team permission to make updates to policies as needed. In order to use the latest policies, the CloudFront function will need to fetch the policies from DynamoDB during execution. One caveat is that CloudFront Functions’ runtime environment does not support dynamic code evaluation and it restricts access to the network/filesystems which inhibits it from being able to query DynamoDB. So, hardcoded policies are used within the CloudFront function code and each time the embargo policies are modified, the CloudFront function needs to be updated and re-deployed.

Figure 3 represents a simple deployment framework which automates this process. Please note that these might be achieved via other means (e.g. Code Pipeline) as well, but this option was chosen because of the simplicity and serverless nature. The framework comprises of following AWS Services:

  • Amazon DynamoDB: NoSQL database that is used as the embargo policy store.
  • AWS Lambda: The serverless component that will update the CloudFront function code and re-deploy.
  • Amazon S3: The storage layer that stores the CloudFront function code template.

Figure 3: CloudFront Function code deployment process

Deployment Solution Walkthrough

  1. Security team updates the embargo policies stored in Amazon DynamoDB Table using AWS SDK /AWS Command Line Interface (AWS CLI)/AWS Management Console.
  2. The change event will trigger the AWS Lambda function that is configured with Amazon DynamoDB streams.
  3. AWS Lambda function (CFF Code Build Function) will take following actions:
    1. Retrieve the latest embargo policies from Amazon DynamoDB Table.
    2. Retrieve the latest code template that is stored in Amazon S3.
    3. Generate a new version of CloudFront Function code using the code template and the embargo policies.
    4. Update the existing CloudFront Function with the latest code and publish.

Deployment Solution Implementation steps

  1. Create a DynamoDB Table

The Amazon DynamoDB table will be the datastore for the embargo policies. For more information on how to create a DynamoDB table, see Create a Table. For this implementation, the table structure will be:

{
  "policy": {
    "M": {
      "country_cd": {
        "S": "us"
      },
      "country_region_cd": {
        "S": "tx"
      },
      "country_region_city": {
        "S": "prosper"
      }
    }
  },
  "policy_id": {
    "S": "p-103" //primary key
  }
}
  1. Create an Amazon S3 Bucket

The CloudFront code template used to generate the CloudFront Function code is stored in an Amazon S3 bucket in the form of a text file e.g. cloudfrontfunction_code_template.txt. For this implementation, following can be used as the template.

function handler(event) {

    var policies = <>; //This is where policies will be injected
    
    var request = event.request;
    var headers = request.headers;
    if (!headers['cloudfront-viewer-city']) {
        // if "cloudfront-viewer-city" header is missing, skip the validation.'
        return request;
    }
    var requestCountry = headers['cloudfront-viewer-country'].value;
    var requestRegion = headers['cloudfront-viewer-country-region'].value;
    var requestCity = headers['cloudfront-viewer-city'].value;
    var matched = policies.items.some(function(e) {
        return this[0].toLowerCase() == e.country_cd.toLowerCase() 
        && this[1].toLowerCase() == e.country_region_cd.toLowerCase()
        && this[2].toLowerCase() == e.country_region_city.toLowerCase()
        }, [requestCountry, requestRegion, requestCity]
    );
    
    if (matched) {
        var response = {
            statusCode: 403,
            statusDescription: 'Forbidden',
        }
        return response
    };
    return request;
}

For more information on how to create an Amazon S3 bucket and upload a file, see the documentation on creating a bucket and uploading an object to your bucket.

  1. Create an AWS Lambda function

A Python-based Lambda function uses the Boto3 AWS SDK for generating the code, updating the CloudFront Function, and publishing it live. For more information about how to create a Python-based Lambda function, see Building Lambda functions with Python. You can use the following code for Lambda function:

import json
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('<name of the dynamodb table>')

cloudfront = boto3.client('cloudfront')
s3 = boto3.resource('s3')

CFF_FUNCTION_NAME = '<CloudFront function name>'
CFF_CODE_TEMPLATE_BUCKET = '<s3 bucket name>'
CFF_CODE_TEMPLATE_KEY = '<name of the template file>'

def lambda_handler(event, context):
    
  scan_response = table.scan()
  policy_json = {
    "items": list(map(lambda x: x['policy'], scan_response['Items']))
  }

  code_template_response = s3.Object(CFF_CODE_TEMPLATE_BUCKET, CFF_CODE_TEMPLATE_KEY)
  code_template: str = code_template_response.get()['Body'].read().decode('utf-8')
  updated_code = code_template.replace('<>', json.dumps(policy_json))

  describe_fn_response = cloudfront.describe_function(
    Name=CFF_FUNCTION_NAME
  )

  fn_etag = describe_fn_response['ETag']
  fn_config = describe_fn_response['FunctionSummary']['FunctionConfig']

  update_fn_response = cloudfront.update_function(
    Name=CFF_FUNCTION_NAME,
    IfMatch=fn_etag,
    FunctionConfig=fn_config,
    FunctionCode=bytes(updated_code, 'utf-8')
  )

  fn_etag = update_fn_response['ETag']
  publish_fn_response = cloudfront.publish_function(
    Name=CFF_FUNCTION_NAME,
    IfMatch=fn_etag
  )
  return {
      'statusCode': 200,
      'body': json.dumps('Cloudfront Function redeployed')
  }
  1. Set up DynamoDB Streams

The CloudFront function needs to be redeployed every time there is a change to the embargo policies. To achieve this, DynamoDB streams is used to invoke the Lambda function which was created in the earlier step. For more information on how to enable DynamoDB streams and configure it to invoke Lambda function, see Enabling a Stream and DynamoDB Streams and AWS Lambda Triggers.

Testing the Solution

For testing the solution, the goal is to block users located in the city of Prosper (State: Texas, Country: United States). Currently deployed embargo policies don’t contain the rule yet.

Figure 4: No embargo policy for “Prosper”

This means that if you try to access the test website (using an image for testing purpose) from a computer located in Prosper, it will work.

Figure 5: Site access allowed

 

Now, add the new policy to the DynamoDB table.

Figure 6: Embargo policy added for “Prosper”

The addition of the new policy will trigger the Lambda function which will in turn re-deploy the CloudFront function with the latest policies. In this case, if you try to access the test website (using an image for testing purpose) from a computer located in Prosper, it will be blocked.

 

Figure 7: Site access blocked

 Clean up Resources:

To avoid ongoing charges to your AWS account, remove the resources you created:

  1. Delete the CloudFront distribution.
  2. Delete the CloudFront Function.
  3. Delete DynamoDB Table.
  4. Delete the S3 Bucket.
  5. Delete the Lambda function.

Conclusion

You are now ready to enable city specific embargo enforcement using Amazon CloudFront. We appreciate your time and look forward to building with you in the future.

If you have questions about this post, start a new thread on the Amazon CloudFront forum or contact AWS Support.

Ajit Puthiyavettle

Ajit Puthiyavettle is a Solution Architect working with enterprise clients, architecting solutions to achieve business outcomes. He is passionate about solving customer challenges with innovative solutions. His experience is with leading DevOps and security teams for enterprise and SaaS (Software as a Service) companies.

Arnab Ghosh

Arnab Ghosh is a Solutions Architect for AWS in North America helping enterprise customers build resilient and cost-efficient architectures. He has over 14 years of experience in architecting, designing, and developing enterprise applications solving complex business problems.