AWS WAF with Terraform

Max Tsogt
System Weakness
Published in
9 min readJul 5, 2023

--

Recently, I’ve been working lot with AWS WAF and I decided to write small piece to understand WAF better and hope that it helps us others as well.

For simplicity, WAF is just traditional firewall we know of and difference is that it works on the Layer 7 instead of Layer 4 (I mean one can argue that today’s firewalls can understand layer 7, but yeah…). Anyways, we looked around in AWS and found out that there are two versions of AWS WAF.

Here is brief overview of both:

  1. AWS WAF Classic: This is the original version of AWS WAF. It allows us to protect our AWS applications and APIs against common web exploits such as SQL injection, Cross-Site Scripting (XSS), etc. We can use AWS WAF Classic to create custom rules that block or allow requests based on conditions like IP addresses, HTTP headers, HTTP body, or URI strings.
  2. AWS WAFv2: This is the newer version of AWS WAF, released in November 2019. It includes several enhancements over the Classic version. For instance, it has a simpler pricing model, it’s easier to deploy and manage, and it provides improved customizability over how we inspect and filter web requests. AWS WAFv2 also integrates better with other AWS services, and it provides features such as managed rule groups and tagging.

The two versions are not interoperable, meaning we can’t use a mix of AWS WAF Classic and AWS WAFv2 in the same web application. Therefore, if we are starting a new project (as we are), it’s recommended to use AWS WAFv2 as it’s the more advanced and better-supported version.

To begin with, we can use the pre-configured managed rule groups that AWS WAFv2 provides. These managed rules are maintained by AWS and cover common threats and vulnerabilities. They are a good way to get started quickly with AWS WAFv2.

Since we use Terraform for deploying (or I should say provisioning), we can use the aws_wafv2_web_acl resource to create and manage AWS WAFv2 Access Control Lists (ACLs), which contain the rules that filter our web traffic.

Here’s a simple example:

resource "aws_wafv2_web_acl" "example" {
name = "example"
description = "Example of a managed rule"
scope = "REGIONAL"

default_action {
block {}
}

rule {t
name = "rateLimitRule"
priority = 0

action {
count {}
}

statement {
rate_based_statement {
limit = 10000
aggregate_key_type = "IP"
}
}

visibility_config {
cloudwatch_metrics_enabled = false
metric_name = "friendly-metric-name"
sampled_requests_enabled = false
}
}

visibility_config {
cloudwatch_metrics_enabled = false
metric_name = "friendly-metric-name"
sampled_requests_enabled = false
}
}

This creates a regional WAFv2 ACL with a rate-based rule that blocks requests once they exceed a limit of 10,000 requests from the same IP address. Adjust the rules to fit your needs.

Is WAF Regional? No. AWS WAF is indeed a global service. It integrates with Amazon CloudFront and the Application Load Balancer services, both of which can be configured to route traffic globally. But AWS WAF also supports Amazon API Gateway (regional) and AWS AppSync.

Below is an example of a Terraform script that creates an AWS WAFv2 Web ACL with AWS Managed Core Rule Set, which is developed and maintained by AWS. It provides essential protections such as preventing SQL Injection, Cross-Site Scripting, HTTP flood attacks, scanning probes and more, which are often considered industry best practices for securing web applications:

provider "aws" {
region = "us-west-2" // replace with your desired AWS region
}

resource "aws_wafv2_web_acl" "example" {
name = "mywebacl"
scope = "REGIONAL"

default_action {
allow {}
}

rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 0

override_action {
count {}
}

statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSet"
sampled_requests_enabled = true
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "webACLVisibilityConfig"
sampled_requests_enabled = true
}

tags = {
Environment = "Production"
Name = "webACL"
}
}

In this example, the WebACL is created with a rule that includes the AWS Managed Core Rule Set. The ‘scope’ is set to “REGIONAL” so it can work with resources like API Gateway. The default_action is set to "ALLOW", which means that if a request doesn't match any of the rules in the ACL, it will be allowed. The override_action is set to "COUNT" which means that the rule action is overridden with COUNT.

The visibility configuration is set up with CloudWatch metrics and sampled requests enabled, which will give you valuable information about the traffic that’s being inspected by the ACL.

Lastly, a set of tags is added to the ACL for easier management.

We would need to associate this WebACL with our resources (API Gateway, CloudFront distribution, etc). To do this, we can use the aws_wafv2_webacl_association resource in Terraform where we defined our CloudFront or AWS Gateway resources. Here is the documentation on Terraform on how to associate -> link.

From above code, you might be wondering (or not) that:

Should I have to define it specific AWS region such as eu-west-2? I thought it was global service?

You’re correct in that AWS WAF is considered a global service, but in the context of Terraform and its provider configurations, a region still needs to be specified. This doesn’t restrict the WAF service itself to that region, rather, it’s required for the AWS provider to make appropriate API calls.

It’s important to note that even though AWS WAF is a global service, when it comes to WAF association, the scope does matter.

  • If you’re using AWS WAF with Amazon CloudFront, the scope is “CLOUDFRONT”, and AWS WAF protects and logs all requests from all AWS Regions.
  • If you’re using AWS WAF with a regional application — an Application Load Balancer, Amazon API Gateway, or AWS AppSync — the scope is “REGIONAL”.

In the Terraform example I provided, the scope is defined as “REGIONAL”. If you’re associating this with CloudFront, you would change this to “CLOUDFRONT”.

So, in our case, our service is fronted by CloudFront and the region can technically be any valid AWS region as WAF configuration for CloudFront doesn’t tie to a specific region. However, in your case, if you’re associating WAF with regional services such as Application Load Balancer (ALB) or API Gateway, the WAF ACL has to be in the same region as these services.

Remember that even though you can technically specify any region in the AWS provider block, it’s considered a good practice to use the region that’s geographically closer to you or where the majority of your infrastructure resides, for better latency and consistency.

Now you might be also wondering that:

How about if we have resources such as AWS Gateway in multiple AWS regions and CloudFront which is global? How do we write the Terraform code?

Given that you have both regional and global resources, you will need to create two separate WebACLs as we are doing for our services, one with a scope of “REGIONAL” and one with a scope of “CLOUDFRONT”. This is because a single WebACL cannot be used for both regional and global resources.

The CloudFront WebACL will apply to your CloudFront distributions regardless of where they are accessed from, as CloudFront is a global service. For your regional resources, such as API Gateway, you will have to create a WebACL in each region where you have these resources and associate them accordingly.

Here’s an example:

provider "aws" {
region = "us-east-1"
}

# This creates a WebACL for your CloudFront distributions
resource "aws_wafv2_web_acl" "cloudfront" {
name = "cloudfront-webacl"
scope = "CLOUDFRONT"

default_action {
allow {}
}

rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 0

override_action {
count {}
}

statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSet"
sampled_requests_enabled = true
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cloudfrontVisibilityConfig"
sampled_requests_enabled = true
}

tags = {
Environment = "Production"
Name = "cloudfrontWebACL"
}
}

provider "aws" {
alias = "ap-northeast-2"
region = "ap-northeast-2"
}

# This creates a WebACL for your regional resources, such as API Gateway
resource "aws_wafv2_web_acl" "regional" {
provider = aws.ap-northeast-2

name = "regional-webacl"
scope = "REGIONAL"

default_action {
allow {}
}

rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 0

override_action {
count {}
}

statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "AWSManagedRulesCommonRuleSet"
sampled_requests_enabled = true
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "regionalVisibilityConfig"
sampled_requests_enabled = true
}

tags = {
Environment = "Production"
Name = "regionalWebACL"
}
}

In this example, we define two AWS providers: one for us-east-1 (which is used for the CloudFront WebACL), because I’d like to see CloudFront and its rules in the same region (it really doesn’t matter, just our preference) and one for ap-northeast-2(which is used for the API Gateway WebACL where our services are located). The alias in the second provider block allows us to specify which provider to use in our regional WebACL.

Please note that you need to replicate the regional WebACL and provider block for each region where you have regional resources and adjust the region and possibly the name for each accordingly. But if you have like many regions, you most likely make it module and call it for each region to keep it DRY, you know.

Lastly, remember to associate the WebACLs with your respective resources using aws_wafv2_web_acl_association. For the CloudFront WebACL, the association will be with your CloudFront distribution. For the regional WebACL, it will be with your regional resource such as API Gateway.

Now you might wonder (this is getting longer, I promise it is the last one):

If there is an association example? I want to define the global WAF in one Terraform project and outputting the arn, so I can use it for few other Terraform projects by importing it. And I want to define the regional WAF in another TF project where we can use it automatically with different regions through modules.

And yes. Sure, I can give you an example of how to set up your Terraform projects so that you can output the ARNs of the WAF ACLs and import them in other projects.

The first step is to create your global and regional WAF ACLs in separate Terraform projects.

Here’s how you could set up your global WAF project:

provider "aws" {
region = "us-west-2"
}

resource "aws_wafv2_web_acl" "cloudfront" {
name = "cloudfront-webacl"
scope = "CLOUDFRONT"
...
# your configuration
...
}

output "cloudfront_waf_acl_arn" {
description = "ARN of the CloudFront WAF ACL"
value = aws_wafv2_web_acl.cloudfront.arn
}

As you can see the point is outputting the arn , so you can use it another project.

And as for your regional WAF:

provider "aws" {
region = "us-west-2"
}

resource "aws_wafv2_web_acl" "regional" {
name = "regional-webacl"
scope = "REGIONAL"
...
# your configuration
...
}

output "regional_waf_acl_arn" {
description = "ARN of the regional WAF ACL"
value = aws_wafv2_web_acl.regional.arn
}

You can make it module and call it for each region as I mentioned above. Remember since it is module, remember to output the value.

As you can see above configurations output the ARN of the WAF ACL because you can then reference in your other Terraform projects. To do this, you will use the terraform_remote_state data source to access the state of your WAF projects:

data "terraform_remote_state" "global_waf" {
backend = "s3"
config = {
bucket = "mybucket"
key = "path/to/my/globalwaf/terraform.tfstate"
region = "us-west-2"
}
}

Replace the S3 bucket and key with the actual S3 location of your Terraform state files. This approach assumes you’re using the S3 backend for Terraform, adjust as needed if you’re using a different backend.

Anyways, finally, here’s an example of how you could use the imported ARNs to associate the WAF ACLs with your resources:

resource "aws_cloudfront_distribution" "example1" {
...
# your configuration
...
web_acl_id = data.terraform_remote_state.global_waf.outputs.cloudfront_waf_acl_arn
...
}

As for regional one (since we are outputting it from the module):

resource "aws_wafv2_web_acl_association" "example2" {
web_acl_arn = example_module.outputs.regional_waf_acl_arn
resource_arn = aws_api_gateway_rest_api.example.execution_arn
}

Make sure to replace the aws_cloudfront_distribution.example.arn and aws_api_gateway_rest_api.example.execution_arn with the ARNs of your own resources.

Note: Make sure that the projects that contain the WAF rules have been applied and that the state files have been pushed to the remote backend before running the other Terraform projects that import this state or modules are outputted. This is because the state needs to exist remotely for global resource before it can be accessed by the terraform_remote_state data source.

Anyways, let’s go until here today. Good luck on building stronger digital defenses and protecting your infra.

--

--

In the vibrant world of fintech, I serve as a DevOps Engineer with a particular focus on establishing secure, efficient pipelines and infra