AWS WAF Implementation Guide: Setting Up with Terraform for Enhanced Security
Tags: aws, beginners, terraform, security
Published: 2023-09-23 12:47:43

To enhance the security of our application, we have implemented AWS WAF in front of API Gateway. During the implementation, we configured WAF, IAM roles, and CloudWatch Logs using Terraform, and this article serves as a memorandum of the process.

Here are the benefits of implementing AWS WAF:

- It can protect web applications from common web attacks.
- It allows for the efficient application of security rules using AWS-managed rule sets.
- It offers flexible security measures by allowing specific rules to be overridden.

https://aws.amazon.com/jp/waf/

## Directory Structure
The directory structure of Terraform is modularized, with configurations separated for each environment (dev, stg, prod). The `modules` directory contains Terraform modules (waf, iam_roles, cloudwatch_logs, etc.) used in the project.

```lua
-- terraform-project/
   -- environments/
      -- dev/
         -- backend.tf
         -- main.tf
      -- stg/
         -- backend.tf
         -- main.tf
      -- prod/
         -- backend.tf
         -- main.tf
   -- modules/
      -- waf/
         -- main.tf
         -- variables.tf
         -- outputs.tf
         -- provider.tf
         -- README.md
      -- iam_roles/
         -- main.tf
         -- variables.tf
         -- outputs.tf
         -- provider.tf
         -- README.md
      -- cloudwatch_logs/
         -- main.tf
         -- variables.tf
         -- outputs.tf
         -- provider.tf
         -- README.md
      -- ...other…
   -- docs/
      -- architecrture.drowio
      -- architecrture.png
```

## Implementation
### WAF
The following Terraform code applies WAF to API Gateway and sets various security rules.

`./modules/waf/main.tf`
```tf:./modules/waf/main.tf
resource "aws_wafv2_web_acl" "api_gateway_waf" {
  name        = "api-gateway-waf"
  description = "Managed rule WAF"
  scope       = "REGIONAL"

  default_action {
    allow {}
  }

  rule {
    name     = "AWS-AWSManagedRulesCommonRuleSet"
    priority = 0

    override_action {
      none {}
    }

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

        rule_action_override {
          action_to_use {
            allow {}
          }
          name = "SizeRestrictions_BODY"
        }
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "AWS-AWSManagedRulesCommonRuleSet"
      sampled_requests_enabled   = true
    }
  }

  rule {
    name     = "AWS-AWSManagedRulesAmazonIpReputationList"
    priority = 1

    override_action {
      none {}
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesAmazonIpReputationList"
        vendor_name = "AWS"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "AWS-AWSManagedRulesAmazonIpReputationList"
      sampled_requests_enabled   = true
    }
  }

  rule {
    name     = "AWS-AWSManagedRulesSQLiRuleSet"
    priority = 2

    override_action {
      none {}
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesSQLiRuleSet"
        vendor_name = "AWS"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "AWS-AWSManagedRulesSQLiRuleSet"
      sampled_requests_enabled   = true
    }
  }

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

resource "aws_wafv2_web_acl_logging_configuration" "waf_logging_config" {
  log_destination_configs = [var.log_group_arn]
  resource_arn            = aws_wafv2_web_acl.api_gateway_waf.arn

  depends_on = [aws_wafv2_web_acl.api_gateway_waf]

  redacted_fields {
    single_header {
      name = "user-agent"
    }
  }
}

// attach API Gateway dev
data "aws_ssm_parameter" "api_gateway_api_dev_arn" {
  name = "/api/gateway/arn/api/dev"
}

resource "aws_wafv2_web_acl_association" "api_dev" {
  resource_arn = data.aws_ssm_parameter.api_gateway_api_dev_arn.value
  web_acl_arn  = aws_wafv2_web_acl.api_gateway_waf.arn

  depends_on = [aws_wafv2_web_acl.api_gateway_waf]
}

// attach API Gateway stg
data "aws_ssm_parameter" "api_gateway_api_stg_arn" {
  name = "/api/gateway/arn/api/stg"
}

resource "aws_wafv2_web_acl_association" "api_stg" {
  resource_arn = data.aws_ssm_parameter.api_gateway_api_stg_arn.value
  web_acl_arn  = aws_wafv2_web_acl.api_gateway_waf.arn
}

// attach API Gateway prod
data "aws_ssm_parameter" "api_gateway_api_prod_arn" {
  name = "/api/gateway/arn/api/prod"
}

resource "aws_wafv2_web_acl_association" "api_prod" {
  resource_arn = data.aws_ssm_parameter.api_gateway_api_prod_arn.value
  web_acl_arn  = aws_wafv2_web_acl.api_gateway_waf.arn
}
```

`./modules/waf/variables.tf`
```tf:./modules/waf/variables.tf
variable "log_group_arn" {
  description = "The ARN of the CloudWatch Logs group"
  type        = string
}
```

- The `aws_wafv2_web_acl` resource creates a Web ACL. Here, a Web ACL named `api-gateway-waf` with a REGIONAL scope is created.
- The `default_action` block sets the default action for requests that do not match any specific rule. Here, the allow action is set. If a request matches a configured rule, the action defined in that rule (e.g., block) is executed.
- The `rule` block sets each rule. This code uses three rule sets managed by AWS.
 - `AWSManagedRulesCommonRuleSet`: A rule set to prevent common vulnerabilities and exploits (e.g., XSS, size restrictions).
 - `AWSManagedRulesAmazonIpReputationList`: A rule set that uses Amazon's IP reputation list (IP addresses of spammers or malware distributors) to filter malicious traffic.
 - `AWSManagedRulesSQLiRuleSet`: A rule set to detect and prevent SQL injection attacks.
- The `visibility_config` block configures the sending of metrics to CloudWatch.
- `log_group_arn` defines the ARN of the CloudWatch Logs group as a variable. This ARN is used when sending logs from WAF to CloudWatch Logs.

#### Overriding Specific Rules in AWS-Managed Rule Sets
Using AWS-managed rule sets is convenient, but there may be times when you want to allow (not block) specific rules. In such cases, you override the settings with `rule_action_override`. Below, requests that match the `SizeRestrictions_BODY` rule (a rule related to the size restrictions of API requests) from `AWSManagedRulesCommonRuleSet` are allowed.

```tf
        rule_action_override {
          action_to_use {
            allow {}
          }
          name = "SizeRestrictions_BODY"
        }
```

This is equivalent to updating the Rule action settings for each rule in the management console.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nff6zu8grky3so5azy0f.png)

### IAM Roles
The following Terraform code creates an IAM role for sending WAF logs to CloudWatch Logs.

`./modules/iam_roles/main.tf`
```tf:./modules/iam_roles/main.tf
resource "aws_iam_role" "waf_logging" {
  name = "waf_logging"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "waf.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "waf_logging" {
  name = "waf_logging"
  role = aws_iam_role.waf_logging.id

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}
EOF
}
```

- The `aws_iam_role` resource creates an IAM role named `waf_logging`.
- The `aws_iam_role_policy` resource attaches a policy to this role. This policy allows WAF to send logs to CloudWatch Logs.

### CloudWatch Logs
The following Terraform code creates a CloudWatch Logs group to store WAF logs.

`./modules/cloudwatch_logs/main.tf`
```tf:./modules/cloudwatch_logs/main.tf
resource "aws_cloudwatch_log_group" "waf_logs" {
  name = "aws-waf-logs-api-gateway"
}
```

`./modules/cloudwatch_logs/outputs.tf`
```tf:./modules/cloudwatch_logs/outputs.tf
output "log_group_arn" {
  description = "The ARN of the CloudWatch Logs group"
  value       = aws_cloudwatch_log_group.waf_logs.arn
}
```

The `aws_cloudwatch_log_group` resource creates a log group named `aws-waf-logs-api-gateway`.

## Applying the Code
Once each module is created, load the modules in `./environments/{environment}/main.tf` and apply them with `terraform apply`.

`./environments/{environment}/main.tf`
```tf:./environments/{environment}/main.tf
module "waf" {
  log_group_arn = module.cloudwatch_logs.log_group_arn

  source = "../../modules/waf"
}

module "cloudwatch_logs" {
  source = "../../modules/cloudwatch_logs"
}

module "iam_roles" {
  source = "../../modules/iam_roles"
}
```

Once applied, the created WebACL will be displayed on the console screen.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1okncfqxdkry6n1lp53g.png)