Managing AWS CloudFront Security Group with AWS Lambda

One of our security groups on Amazon Web Services (AWS) allows access to an Elastic Load Balancer (ELB) from one of our Amazon CloudFront distributions. Traffic from CloudFront can originate from a number of a different source IP addresess that Amazon publishes. However, there is no pre-built security group to allow inbound traffic from CloudFront.

I constructed an AWS Lambda function to periodically update our security group so that we can ensure all CloudFront IP addresses are permitted to access our ELB.

AWS Lambda

AWS Lambda allows you to execute functions in a few different languages (Python, Java, and Node.js) in response to events. One of these events can be the triggering of a regular schedule. In this case, I created a scheduled event with an Amazon CloudWatch rule to execute a lambda function on an hourly basis.

CloudWatch Schedule to Lambda Function

The Idea

The core of my code involves calls to authorize_ingress and revoke_ingress using the boto3 library for AWS. AWS Lambda makes the boto3 library available for Python functions.


print("the following new ip addresses will be added:")
print(authorize_dict['ipranges'])

print("the following new ip addresses will be removed:")
print(revoke_dict['ipranges'])

security_group.authorize_ingress(ippermissions=[authorize_dict])
security_group.revoke_ingress(ippermissions=[revoke_dict])

Amazon publishes the IP address ranges of its various services online.


response = urllib2.urlopen('https://ip-ranges.amazonaws.com/ip-ranges.json')
json_data = json.loads(response.read())
new_ip_ranges = [ x['ip_prefix'] for x in json_data['prefixes'] if x['service'] == 'cloudfront' ]
print(new_ip_ranges)

I can easily compare the allowed ingress address ranges in an existing security group with those retrieved from the published ranges. The authorized_ingress and revoke_ingress functions then allow me to make modifications to the security group to keep it up-to-date, and permit traffic from CloudFront to access my ELB.


for ip in new_ip_ranges:
    if ip not in current_ip_ranges:
        authorize_dict['ipranges'].append({u'cidrip': ip})

for ip in current_ip_ranges:
    if ip not in new_ip_ranges:
        revoke_dict['ipranges'].append({u'cidrip': ip})

The AWS Lambda Function

The full lambda function is written as a standard lambda_handler for AWS. In this case, the event and context are ignored, and the code is just executed on a regular schedule.

Lambda Function

Notice that the existing security group is directly referenced as sg-3xxexx5x.


from __future__ import print_function

import json, urllib2, boto3


def lambda_handler(event, context):
    response = urllib2.urlopen('https://ip-ranges.amazonaws.com/ip-ranges.json')
    json_data = json.loads(response.read())
    new_ip_ranges = [ x['ip_prefix'] for x in json_data['prefixes'] if x['service'] == 'cloudfront' ]
    print(new_ip_ranges)

    ec2 = boto3.resource('ec2')
    security_group = ec2.securitygroup('sg-3xxexx5x')
    current_ip_ranges = [ x['cidrip'] for x in security_group.ip_permissions[0]['ipranges'] ]
    print(current_ip_ranges)

    params_dict = {
        u'prefixlistids': [],
        u'fromport': 0,
        u'ipranges': [],
        u'toport': 65535,
        u'ipprotocol': 'tcp',
        u'useridgrouppairs': []
    }

    authorize_dict = params_dict.copy()
    for ip in new_ip_ranges:
        if ip not in current_ip_ranges:
            authorize_dict['ipranges'].append({u'cidrip': ip})

    revoke_dict = params_dict.copy()
    for ip in current_ip_ranges:
        if ip not in new_ip_ranges:
            revoke_dict['ipranges'].append({u'cidrip': ip})

    print("the following new ip addresses will be added:")
    print(authorize_dict['ipranges'])

    print("the following new ip addresses will be removed:")
    print(revoke_dict['ipranges'])

    security_group.authorize_ingress(ippermissions=[authorize_dict])
    security_group.revoke_ingress(ippermissions=[revoke_dict])

    return {'authorized': authorize_dict, 'revoked': revoke_dict}

The Security Policy

The above lamdba function presumes permissions to be able to edit the referenced security group. These permissions can be configured with an AWS Identity and Access Management (IAM) policy, applied to the role which the lamdba function executes as.

Lambda function role

Notice that the security group resource, sg-3xxexx5x, is specifically scoped to the us-west-2 AWS region.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeSecurityGroups",
                "ec2:DescribeNetworkAcls"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:AuthorizeSecurityGroupIngress",
                "ec2:RevokeSecurityGroupIngress"
            ],
            "Resource": "arn:aws:ec2:us-west-2:*:security-group/sg-3xxexx5x"
        }
    ]
}

Making It All Work

In order to get everything hooked up correctly, an appropriate security group needs to exist. The identifier for the group needs to be referenced in both the Lambda script, and the policy used by the role that the lambda script executes as. The IAM policy uses the Amazon Resource Name (ARN) instead of the security group identifier. The AWS Lambda function presumes that Amazon will publish changes to the CloudFront IP address range in a timely manner, and that running the function once per hour will be sufficient to grant ingress permissions on the security group. If the CloudFront ranges change frequently, or traffic is particularly crucial, the frequency of the lambda function run should be increased.

Conversation
  • Gerardo Poggio says:

    Justin,
    A small improvement to your method would be to trigger the Lambda function only when the prefixes are updated instead of checking regularly. You can do so by subscribing your function to the official SNS topic for this purpose http://docs.aws.amazon.com/general/latest/gr/aws-ip-ranges.html#subscribe-notifications
    You can also add a step to check the MD5 checksum of the JSON file you download.

    Gerardo.

  • Brad says:

    Thanks for a great start, however your code needs to be updated, the syntax and case for many of the boto3 commands was wrong above, also you created a security group allowing all ports from 0-65535 for the CIDR ranges, that’s very much too broad and should be scoped to just the desired ports (e.g. 80 or 443) etc.

    Here is an updated example:

    from __future__ import print_function

    import json, urllib2, boto3

    def lambda_handler(event, context):
    response = urllib2.urlopen(‘https://ip-ranges.amazonaws.com/ip-ranges.json’)
    json_data = json.loads(response.read())
    new_ip_ranges = [ x[‘ip_prefix’] for x in json_data[‘prefixes’] if x[‘service’] == ‘CLOUDFRONT’ ]
    print(new_ip_ranges)

    ec2 = boto3.resource(‘ec2’)
    security_group = ec2.SecurityGroup(‘sg-6632fa01’)
    current_ip_ranges = [ x[‘CidrIp’] for x in security_group.ip_permissions[0][‘IpRanges’] ]
    print(current_ip_ranges)

    params_dict = {
    u’PrefixListIds’: [],
    u’FromPort’: 80,
    u’IpRanges’: [],
    u’ToPort’: 80,
    u’IpProtocol’: ‘tcp’,
    u’UserIdGroupPairs’: []
    }

    authorize_dict = params_dict.copy()
    for ip in new_ip_ranges:
    if ip not in current_ip_ranges:
    authorize_dict[‘IpRanges’].append({u’CidrIp’: ip})

    print(“the following new ip addresses will be added:”)
    print(authorize_dict[‘IpRanges’])
    security_group.authorize_ingress(IpPermissions=[authorize_dict])

    revoke_dict = params_dict.copy()
    for ip in current_ip_ranges:
    if ip not in new_ip_ranges:
    revoke_dict[‘IpRanges’].append({u’CidrIp’: ip})

    print(“the following new ip addresses will be removed:”)
    print(revoke_dict[‘IpRanges’])

    security_group.revoke_ingress(IpPermissions=[revoke_dict])

  • Equivalent says:

    Thank you for this. Helped me a lot. However I found that the python script was not entirely correct. I’ve created T.I.L. blog on this article
    http://www.eq8.eu/tils/31-aws-lambda-to-configure-ec2-security-group-for-cloudfront-access there i have version of python that worked for me

    Some updates include
    * if current_ips returns empty array it would fail – fixed
    * allow only port range 80-443 (between http to https) …I don’t know python, would be nicer if it was just two ports instead of range
    * fix bunch of boto3 errors

  • Comments are closed.