August 29, 2021

WAFv2 CloudFront CDK

In the last post I covered off how to create a REGIONAL WAF in CDK. In this post I’m going to create a CLOUDFRONT WAF.

This is a little bit more involved. I’m going to assume that your application stack is not in us-east-1 and thus we’ll need to create another stack in us-east-1. This is going to use several of the tricks we discussed in an earlier post.

Existing

Let’s say you have an existing stack that has a CloudFront distribution in it.

...
        dist = cloudfront.Distribution(self, "distribution",
            domain_names=['www.example.com'],
            certificate=mycert,
            default_behavior={
                'origin': origins.HttpOrigin('myorigin.example.com')
            }
        )

Nothing crazy about this.

But this stack is deployed in us-west-2.

We need to do a few things to get a WAF attached to our CloudFront distribution.

  1. Create a CLOUDFRONT Web ACL.
  2. Record the Web ACL ARN in parameter store.
  3. Retrieve the ARN
  4. Include it in our distribution definition above.

Deploy WAF

First we need to create a new stack.

Edit app.py and add a new stack:

...
from waf.waf_stack import WafStack
...
waf_stack = WafStack(app, "WafStack",
    env=cdk.Environment(region='us-east-1')
    )
...
my_app.add_dependency(waf_stack)

Now create your boiler plate for WafStack:

mkdir waf
touch waf/__init__.py
touch waf/waf_stack.py
touch waf/wafv2.py

Let’s first populate our slitghtyly different construct from our REGIONAL version:

"""Implement a v2 WAF"""

from aws_cdk import (
  aws_wafv2 as waf,
  core as cdk
)

class WAFv2(cdk.Construct):
    """Implement a v2 WAF"""

    def __init__(self, scope: cdk.Construct, construct_id: str, default_action="allow", **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        self.web_acl=waf.CfnWebACL(self, "WAF ACL",
          default_action={ default_action: {} },
          scope="CLOUDFRONT",
          visibility_config={
            "sampledRequestsEnabled": True,
            "cloudWatchMetricsEnabled": True,
            "metricName": "web-acl",
          },
          rules=[
            {
              "name": "Custom-RateLimit500",
              "priority": 0,
              "action": {
                "block": {}
              },
              "visibilityConfig": {
                "sampledRequestsEnabled": True,
                "cloudWatchMetricsEnabled": True,
                "metricName": "Custom-RateLimit500"
              },
              "statement": {
                "rateBasedStatement": {
                  "limit": 500,
                  "aggregateKeyType": "IP"
                }
              }
            },
            {
              "priority": 1,
              "overrideAction": { "none": {} },
              "visibilityConfig": {
                "sampledRequestsEnabled": True,
                "cloudWatchMetricsEnabled": True,
                "metricName": "AWS-AWSManagedRulesAmazonIpReputationList",
              },
              "name": "AWS-AWSManagedRulesAmazonIpReputationList",
              "statement": {
                "managedRuleGroupStatement": {
                  "vendorName": "AWS",
                  "name": "AWSManagedRulesAmazonIpReputationList",
                },
              },
            },
            {
              "priority": 2,
              "overrideAction": { "none": {} },
              "visibilityConfig": {
                "sampledRequestsEnabled": True,
                "cloudWatchMetricsEnabled": True,
                "metricName": "AWS-AWSManagedRulesCommonRuleSet",
              },
              "name": "AWS-AWSManagedRulesCommonRuleSet",
              "statement": {
                "managedRuleGroupStatement": {
                  "vendorName": "AWS",
                  "name": "AWSManagedRulesCommonRuleSet",
                },
              },
            },
            {
              "priority": 3,
              "overrideAction": { "none": {} },
              "visibilityConfig": {
                "sampledRequestsEnabled": True,
                "cloudWatchMetricsEnabled": True,
                "metricName": "AWS-AWSManagedRulesKnownBadInputsRuleSet",
              },
              "name": "AWS-AWSManagedRulesKnownBadInputsRuleSet",
              "statement": {
                "managedRuleGroupStatement": {
                  "vendorName": "AWS",
                  "name": "AWSManagedRulesKnownBadInputsRuleSet",
                },
              },
            },
            {
              "priority": 4,
              "overrideAction": { "none": {} },
              "visibilityConfig": {
                "sampledRequestsEnabled": True,
                "cloudWatchMetricsEnabled": True,
                "metricName": "AWS-AWSManagedRulesSQLiRuleSet",
              },
              "name": "AWS-AWSManagedRulesSQLiRuleSet",
              "statement": {
                "managedRuleGroupStatement": {
                  "vendorName": "AWS",
                  "name": "AWSManagedRulesSQLiRuleSet",
                },
              },
            }
          ]
        )

    @property
    def web_acl_arn(self):
        """return the arn of the acl"""
        return self.web_acl.attr_arn

There are two key differences to note:

  1. There is no resource association.
  2. The scope is now CLOUDFRONT instead of REGIONAL.

Now that we have that, let’s actually stand the stack up. To do this we’ll populate waf___stack.py with the following:

"""Create a WAF in us-east-1 for use by CloudFront"""
import os
from aws_cdk import (
        core as cdk,
        aws_ssm as ssm
        )
from waf.wafv2 import WAFv2

class WafStack(cdk.Stack):
    """Create a WAF in us-east-1 for use by CloudFront"""
    def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        waf_acl = WAFv2(self, 'waf_acl')

        ssm.StringParameter(self, 'waf_acl_arn',
            parameter_name='waf_acl_arn',
            description='WAF ACL ARN',
            string_value=waf_acl.web_acl_arn
            )

This is a very simple stack that is creating a WAF waf_acl = WAFv2(self, 'waf_acl' and then we store it in a parameter with the name: waf_acl_arn.

Storing the ARN in parameter store will become key in our regional stack.

Retreiving cross-region parameters

Now we are going to use the example from my previous post on CDK Tricks.

Back in your MyApp stack let’s add a new construct that can retrieve parameters given a name and region:

Create a file called ssm_parameter_reader.py in your region stack (I’ve been referring to it as MyApp) and paste the following into it:

"""Retrieve a paramater"""
from datetime import datetime, timezone
from aws_cdk.custom_resources import (
        AwsCustomResource,
        AwsSdkCall,
        AwsCustomResourcePolicy,
        PhysicalResourceId)
from aws_cdk import (
        core as cdk)

class SSMParameterReader(AwsCustomResource):
    """Obtain an SSM Paratmer"""
    def __init__(self, scope: cdk.Construct,
            construct_id: str,
            parameter_name: str,
            region: str,
            **kwargs
        ):

        self.parameter_name = parameter_name
        self.region = region

        dt_now = datetime.now()
        dt_now = dt_now.replace(tzinfo=timezone.utc)

        ssm_param = AwsSdkCall(
            service='SSM',
            action='getParameter',
            parameters={'Name': self.parameter_name},
            region=self.region,
            # physical_resource_id=PhysicalResourceId.of(str(dt_now))
            physical_resource_id=PhysicalResourceId.of(self.parameter_name+'-'+self.region+'v5')
                )

        super().__init__(scope, construct_id,
            on_update=ssm_param,
            policy=AwsCustomResourcePolicy.from_sdk_calls(
                resources=AwsCustomResourcePolicy.ANY_RESOURCE
                ),
             **kwargs
        )

    @property
    def parametervalue(self):
        """Return the response"""
        return str(self.get_response_field('Parameter.Value'))

There is an interesting item here that I want to discuss. The Physical resource Id. There is some discussion on the Internet in various places about how to handle this. I don’t think either solution that I am presenting here is perfect (one that changes every time, or one that is versioned manually). I’ll leave it as an exercise for the reader to decide which is best for them.

Tying it all together

Now that we can build a WAF in us-east-1 in a stack with a dependency, we can now add it to our existing stack. Let’s see how we tie it all together.

Firstly let’s add our new construct add pull in the Web ACL ARN.

...
import myapp.ssm_parameter_reader as ssmr
...
        web_acl_arn = ssmr.SSMParameterReader(self, 'web_acl_arn',
                parameter_name='waf_acl_arn',
                region='us-east-1'
                )
...
        dist = cloudfront.Distribution(self, "distribution",
            domain_names=['www.example.com'],
            certificate=mycert,
            default_behavior={
                'origin': origins.HttpOrigin('myorigin.example.com')
            }
        )

Now that we can retrieve our Parameter. Let’s add it to our distribution.

        dist = cloudfront.Distribution(self, "distribution",
            domain_names=['www.example.com'],
            certificate=mycert,
            web_acl_id=web_acl_arn.parametervalue,
            default_behavior={
                'origin': origins.HttpOrigin('myorigin.example.com')
            }
        )

And that’s it. We’ve used a number of our tricks to build a WAF in an alternate region and save the ARN of that WAF retrievee the ARN and then attach the WAF to CloudFront.

© Greg Cockburn

Powered by Hugo & Kiss.