Dynamic nightmare for Terraforming AWS WAFV2

Kay Ren Fa
6 min readSep 11, 2023

--

A couple of weeks ago, I started working on implementing Web Application Firewall (WAF) on our ALB. A quick introduction of WAF, it is an AWS resource that can be associated to Cloudfront, ALB and/or API Gateway API. It can be used to inspect all request that go through the resource and then it blocks, challenges, captcha, counts or allows the request if it matches certain rules. The rules can be self defined like IP Set, Geolocation or managed rules like Cross-site scripting, SQL Injection.

Going through the Terraform documentation, I was blown away how tedious it is to implement the resources. Most of the AWS resources provides granular sub-resource, for example for IAM, there is one for policy, role and one to associate both together.

resource "aws_iam_group" "group" {
name = "test-group"
}

data "aws_iam_policy_document" "policy" {
statement {
effect = "Allow"
actions = ["ec2:Describe*"]
resources = ["*"]
}
}

resource "aws_iam_policy" "policy" {
name = "test-policy"
description = "A test policy"
policy = data.aws_iam_policy_document.policy.json
}

resource "aws_iam_policy_attachment" "test-attach" {
name = "test-attachment"
groups = [aws_iam_group.group.name]
policy_arn = aws_iam_policy.policy.arn
}

Whereas for WAF, it is one single resource that tries to do everything at one go which is very different from others. Even for AWS step function that can have complex rules, it takes in a JSON input of the whole state machine. AWS CodeDeploy/CodePipeline that has a long set of attributes or nested blocks, it wasn’t that painful to implement. Hence, it blew my mind that it doesn’t follow the same design principles like the others. Going through both AWS documentation and Terraform source code, I guess it is mostly AWS “fault” to implement such a convoluted resource since there are no sub-resource to create.

The reason why it grinds my gear is because having a smaller set of “resources” allows us to have more flexibility to add extensibility from variables without much hassle. For wafv2_acl`, we will have to add numerous dynamic blocks for us to have extensibility. Not saying dynamic blocks are bad, but having too many makes the code unreadable and even harder to visualise the end output as dynamic iterates and generates a set of code.

To refresh everyone’s memory how dynamic block works on Terraform. Dynamic can be used to generate copies of block. Below are 3 snippet of code, where first 2 generate the same output, and 3rd doesn’t.

//Version A
resource "aws_something" "main" {
rule {
attribute = "valueA"
}
rule {
attribute = "valueB"
}
rule {
attribute = "valueC"
}
}

//Version B
variable "input_variable" {
type = list
default = ["valueA", "valueB", "valueC"]
}

resource "aws_something" "main_same" {
dynamic "rule"{
for_each = var.input_variable
content {
attribute = rule.value
}
}
}

//Version C - Wrong example as it create an array of map
resource "aws_something" "main_wrong" {
rule = [for s in var.input_variable : {attribute:s}]
}
/*
resource "aws_something" "main_wrong_output" {
rule = [
{attribute:"valueA"},
{attribute:"valueB"},
{attribute:"valueC"}
]
}
*/

If you compare Version C and Version A, there is a slight difference. Version C takes in an array, but Version A itself is a block of “rule”. I have slight bias-ness for using map object as provides more extensibility than dynamic. WAF for terraform further amplify this problem by a few fold with by needing multiple nested blocks.

Though I agree that WAF provides an extensive customisation and not everyone has the same use case. But I think for 80% of the users are simple minded people like me. I use it with a simple use case, scan all requests, block requests that match the rules, else allow. As mentioned, everyone has different needs, hence, there is no silver bullet to implement WAF.

Fear not, for this 80% group of people, I have simplified the implementation.

resource "aws_wafv2_web_acl" "main" {
name = "waf-${var.name}-acl-alb"
scope = "REGIONAL"

default_action {
allow {}
}

rule {
name = "waf-block-ipset"
priority = 0
action {
block {}
}
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.block.arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "waf-block-ipset"
sampled_requests_enabled = true
}
}

dynamic "rule" {
for_each = toset(var.rules)
content {
name = rule.value.name
priority = rule.value.priority
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = rule.value.name
vendor_name = rule.value.vendor_name
dynamic "managed_rule_group_configs" {
for_each = rule.value.name == "AWSManagedRulesBotControlRuleSet" ? [1] : []
content {
aws_managed_rules_bot_control_rule_set {
inspection_level = "COMMON"
}
}
}
dynamic "rule_action_override" {
for_each = rule.value.allow
content {
name = rule_action_override.value
action_to_use {
allow {}
}
}
}
dynamic "rule_action_override" {
for_each = rule.value.block
content {
name = rule_action_override.value
action_to_use {
block {}
}
}
}
dynamic "rule_action_override" {
for_each = rule.value.count
content {
name = rule_action_override.value
action_to_use {
count {}
}
}
}
dynamic "rule_action_override" {
for_each = rule.value.challenge
content {
name = rule_action_override.value
action_to_use {
challenge {}
}
}
}
dynamic "rule_action_override" {
for_each = rule.value.captcha
content {
name = rule_action_override.value
action_to_use {
captcha {}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = rule.value.name
sampled_requests_enabled = true
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cw-${var.name}-waf-alb"
sampled_requests_enabled = true
}
}

resource "aws_wafv2_web_acl_association" "main" {
resource_arn = var.alb.alb_arn
web_acl_arn = aws_wafv2_web_acl.main.arn
}

resource "aws_wafv2_ip_set" "block" {
name = "waf-${var.name}-block-ipset-alb"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = var.block_ip_set
}

variable "rules" {
type = list(any)
default = [{
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
priority = 1
allow = [
"SizeRestrictions_BODY",
"CrossSiteScripting_BODY"
]
block = [
"NoUserAgent_HEADER",
"UserAgent_BadBots_HEADER",
"SizeRestrictions_QUERYSTRING",
"SizeRestrictions_Cookie_HEADER",
"SizeRestrictions_URIPATH",
"EC2MetaDataSSRF_BODY",
"EC2MetaDataSSRF_COOKIE",
"EC2MetaDataSSRF_URIPATH",
"EC2MetaDataSSRF_QUERYARGUMENTS",
"GenericLFI_QUERYARGUMENTS",
"CrossSiteScripting_URIPATH",
"GenericLFI_URIPATH",
"GenericLFI_BODY",
"RestrictedExtensions_URIPATH",
"RestrictedExtensions_QUERYARGUMENTS",
"GenericRFI_QUERYARGUMENTS",
"GenericRFI_BODY",
"GenericRFI_URIPATH",
"CrossSiteScripting_COOKIE",
"CrossSiteScripting_QUERYARGUMENTS",
]
captcha = []
challenge = []
count = []
}, {
name = "AWSManagedRulesLinuxRuleSet"
vendor_name = "AWS"
priority = 2
allow = []
block = [
"LFI_URIPATH",
"LFI_QUERYSTRING",
"LFI_HEADER"
]
captcha = []
challenge = []
count = []
}, {
name = "AWSManagedRulesUnixRuleSet"
vendor_name = "AWS"
priority = 3
allow = []
block = [
"UNIXShellCommandsVariables_QUERYARGUMENTS",
"UNIXShellCommandsVariables_BODY",
]
captcha = []
challenge = []
count = []
}, {
name = "AWSManagedRulesSQLiRuleSet"
vendor_name = "AWS"
priority = 4
allow = []
block = [
"SQLi_QUERYARGUMENTS",
"SQLiExtendedPatterns_QUERYARGUMENTS",
"SQLi_BODY",
"SQLiExtendedPatterns_BODY",
"SQLi_COOKIE",
]
captcha = []
challenge = []
count = []
}, {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
priority = 5
allow = []
block = [
"JavaDeserializationRCE_HEADER",
"JavaDeserializationRCE_BODY",
"JavaDeserializationRCE_URIPATH",
"JavaDeserializationRCE_QUERYSTRING",
"Host_localhost_HEADER",
"PROPFIND_METHOD",
"ExploitablePaths_URIPATH",
"Log4JRCE_HEADER",
"Log4JRCE_QUERYSTRING",
"Log4JRCE_BODY",
"Log4JRCE_URIPATH",
]
captcha = []
challenge = []
count = []
}, {
name = "AWSManagedRulesBotControlRuleSet"
vendor_name = "AWS"
priority = 6
allow = [
"SignalAutomatedBrowser",
"CategoryHttpLibrary",
"SignalNonBrowserUserAgent"
]
block = []
captcha = []
challenge = []
count = [
"CategoryAdvertising",
"CategoryArchiver",
"CategoryContentFetcher",
"CategoryEmailClient",
"CategoryLinkChecker",
"CategoryMiscellaneous",
"CategoryMonitoring",
"CategoryScrapingFramework",
"CategorySearchEngine",
"CategorySecurity",
"CategorySeo",
"CategorySocialMedia",
"CategoryAI",
"SignalKnownBotDataCenter"
]
}
]
}

variable "alb" {
type = map(any)
description = "Map of Application (with alb) dependency outputs"
}

variable "name" {
default = "resource"
type = string
}

variable "block_ip_set" {
default = []
type = list(string)
description = "List of IP to block"
}

If you see the `aws_wafv2_web_acl` resource, it has layers of dynamic block. But with the simple list of variables, it is much easier to configure the rules many assumption that we reuse the same name as the rules, and little to no customisation to these rules. At least its less painful to customise the input now! With this code, you can have “count” for dev and staging environment and “block” for production with the same implementation.

I hope the above code solves some of the pain point when introducing WAF ACL. Do note that above does not cover all scenario, hence, feel free to extend to the code! Til next time!

--

--