cloudposse / terraform-aws-waf

https://cloudposse.com/accelerate
Apache License 2.0
40 stars 57 forks source link

When `redacted_fields` variable property `single_header` list has more than one header item #84

Open kevinneufeld opened 3 months ago

kevinneufeld commented 3 months ago

Describe the Bug

When single_header property has more than one header in the list, Terraform PLAN fails with Error: Too many single_header blocks

 Error: Too many single_header blocks

   on ../../main.tf line 85, in resource "aws_wafv2_web_acl_logging_configuration" "default":
   85:         content {

No more than 1 "single_header" blocks are allowed

Expected Behavior

Since redacted_fields can only accept one block of each property type ( method, uri_path, query_string and single_header), the expectation is for multiple redacted_fields for each single_header

# When `redacted_fields` is:
redacted_fields = {
  default = {
     single_header = [
       "accept-encoding",
       "accept-language"
     ]
  }
}

# The plan should be something like this:
# module.waf.aws_wafv2_web_acl_logging_configuration.default[0] will be created
  + resource "aws_wafv2_web_acl_logging_configuration" "default" {
      + id                      = (known after apply)
      + log_destination_configs = (known after apply)
      + resource_arn            = (known after apply)

      + redacted_fields {
          + single_header {
              + name = "accept-encoding"
            }
        }
      + redacted_fields {
          + single_header {
              + name = "accept-language"
            }
        }
    }
  ...truncated output

Steps to Reproduce

# examples/complete/main.tf
provider "aws" {
  region = var.region
}

resource "aws_cloudwatch_log_group" "default" {
  # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/wafv2_web_acl_logging_configuration#log_destination_configs
  # data firehose, log group, or bucket name must be prefixed with `aws-waf-logs-`
  name = format("aws-waf-logs-%s", module.this.id)
  tags = module.this.tags
}

module "waf" {
  source = "../.."

  visibility_config = {
    cloudwatch_metrics_enabled = false
    metric_name                = "rules-example-metric"
    sampled_requests_enabled   = false
  }

  log_destination_configs = [aws_cloudwatch_log_group.default.arn]
  redacted_fields = {
    default = {
     single_header = [
       "accept-encoding",
       "accept-language"
     ]
    }
  }

  # https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html
  managed_rule_group_statement_rules = [
    {
      name     = "AWS-AWSManagedRulesAdminProtectionRuleSet"
      priority = 1

      statement = {
        name        = "AWSManagedRulesAdminProtectionRuleSet"
        vendor_name = "AWS"
      }

      visibility_config = {
        cloudwatch_metrics_enabled = true
        sampled_requests_enabled   = true
        metric_name                = "AWS-AWSManagedRulesAdminProtectionRuleSet"
      }
    },
    {
      name     = "AWS-AWSManagedRulesAmazonIpReputationList"
      priority = 2

      statement = {
        name        = "AWSManagedRulesAmazonIpReputationList"
        vendor_name = "AWS"
      }

      visibility_config = {
        cloudwatch_metrics_enabled = true
        sampled_requests_enabled   = true
        metric_name                = "AWS-AWSManagedRulesAmazonIpReputationList"
      }
    },
    {
      name     = "AWS-AWSManagedRulesCommonRuleSet"
      priority = 3

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

      visibility_config = {
        cloudwatch_metrics_enabled = true
        sampled_requests_enabled   = true
        metric_name                = "AWS-AWSManagedRulesCommonRuleSet"
      }
    },
    {
      name     = "AWS-AWSManagedRulesKnownBadInputsRuleSet"
      priority = 4

      statement = {
        name        = "AWSManagedRulesKnownBadInputsRuleSet"
        vendor_name = "AWS"
      }

      visibility_config = {
        cloudwatch_metrics_enabled = true
        sampled_requests_enabled   = true
        metric_name                = "AWS-AWSManagedRulesKnownBadInputsRuleSet"
      }
    },
    # https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-bot.html
    {
      name     = "AWS-AWSManagedRulesBotControlRuleSet"
      priority = 5

      statement = {
        name        = "AWSManagedRulesBotControlRuleSet"
        vendor_name = "AWS"

        rule_action_override = {
          CategoryHttpLibrary = {
            action = "block"
            custom_response = {
              response_code = "404"
              response_header = {
                name  = "example-1"
                value = "example-1"
              }
            }
          }
          SignalNonBrowserUserAgent = {
            action = "count"
            custom_request_handling = {
              insert_header = {
                name  = "example-2"
                value = "example-2"
              }
            }
          }
        }

        managed_rule_group_configs = [
          {
            aws_managed_rules_bot_control_rule_set = {
              inspection_level = "COMMON"
            }
          }
        ]
      }

      visibility_config = {
        cloudwatch_metrics_enabled = true
        sampled_requests_enabled   = true
        metric_name                = "AWS-AWSManagedRulesBotControlRuleSet"
      }
    }
  ]

  byte_match_statement_rules = []
  rate_based_statement_rules = []
  size_constraint_statement_rules = []
  xss_match_statement_rules = []
  sqli_match_statement_rules = []
  geo_match_statement_rules = []
  geo_allowlist_statement_rules = []
  regex_match_statement_rules = []
  ip_set_reference_statement_rules = []

  context = module.this.context
}

Screenshots

No response

Environment

No response

Additional Context

One possible (BREAKING) fix would be to change the redacted_fields from a map object to just an object and separate the dynamic blocks.

# variables Line#997: https://github.com/cloudposse/terraform-aws-waf/blob/main/variables.tf#L977
variable "redacted_fields" {
  type = object({
    method        = optional(bool, false)
    uri_path      = optional(bool, false)
    query_string  = optional(bool, false)
    single_header = optional(list(string), [])
  })

  default  = {}
  nullable = false
}

# main.tf Line#22: https://github.com/cloudposse/terraform-aws-waf/blob/main/main.tf#L22
resource "aws_wafv2_web_acl_logging_configuration" "default" {
  count = local.enabled && length(var.log_destination_configs) > 0 ? 1 : 0

  resource_arn            = one(aws_wafv2_web_acl.default[*].arn)
  log_destination_configs = var.log_destination_configs

  # Method
  dynamic "redacted_fields" {
    for_each = var.redacted_fields.method ? [true] : []
    content {
      method {}
    }
  }
  # Query String
  dynamic "redacted_fields" {
    for_each = var.redacted_fields.query_string ? [true] : []
    content {
      query_string {}
    }
  }
  # Uri Path
  dynamic "redacted_fields" {
    for_each = var.redacted_fields.uri_path ? [true] : []
    content {
      uri_path {}
    }
  }
  # Single Header
  dynamic "redacted_fields" {
    for_each = toset(var.redacted_fields.single_header)
    content {
      single_header {
        name = redacted_fields.value
      }
    }
  }
... truncated - the rest of the code is not included.