Skip to content

Commit

Permalink
chore: optionally add WAF & access logs to ALB (#355)
Browse files Browse the repository at this point in the history
* chore(wip): optionally add WAF & access logs to ALB

* fix: default acl arn

* [WIP] Add access logs to alb

* [WIP] Fix s3 arn

* chore: simplify WAF: users only need to pass in rules

* we only need regional WAF not global

* reject requests that didn't go through Cloudflare by default

* add link for documentation

* better documentation

* [WIP] Use override action

* [WIP] refactor

* fix: cloudflare header

* [WIP] Fix single header name

* chore: allow requests only to domain

* fix: prio for rules

---------

Co-authored-by: Sam Kah Chiin <[email protected]>
  • Loading branch information
swiknaba and samkahchiin authored Jul 3, 2024
1 parent 8a92dcd commit 5d24f0c
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 0 deletions.
32 changes: 32 additions & 0 deletions aws/ecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The `idle_timeout` is set to 60 seconds. Ensure that your webserver's keep-alive

When using Rails with Puma, the default timeout is 20 seconds. This can be changed by setting the `persistent_timeout` option in `config/puma.rb`.

If you enable WAF, and you don't specify any further rules, then the default rule will be to block all traffic.

## Usage

```terraform
Expand Down Expand Up @@ -139,6 +141,36 @@ module "ecs" {
scale_down_treat_missing_data = "breaching"
scale_up_treat_missing_data = "missing"
}
# WAF
enable_waf = false
domain_name = "example.com" # only requests originating on this domain will be allowed
waf_rules = [
{
name = "AllowCloudflare"
priority = 1
action_type = "ALLOW"
header_name = "X-Custom-Header"
header_value = "your-secret-value" # inject some secret to the headers in CF
positional_constraint = "EXACTLY"
text_transformation = "NONE"
},
{
name = "BlockOtherTraffic"
priority = 2
action_type = "BLOCK"
header_name = "X-Other-Header"
header_value = "block-value"
positional_constraint = "CONTAINS"
text_transformation = "LOWERCASE"
}
]
# Access Logs, stored in S3
# see: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html
enable_access_logs = false
access_logs_bucket = ""
access_logs_prefix = "lb-logs"
}
```

Expand Down
61 changes: 61 additions & 0 deletions aws/ecs/alb.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,67 @@ resource "aws_alb" "alb" {
Project = var.project
Environment = var.environment
}

dynamic "access_logs" {
for_each = var.enable_access_logs ? [1] : []
content {
bucket = var.access_logs_bucket
prefix = var.access_logs_prefix
enabled = true
}
}
}

# Access logs
data "aws_elb_service_account" "main" {}

data "aws_iam_policy_document" "lb_logs" {
policy_id = "lb_logs"

statement {
actions = [
"s3:PutObject",
]
effect = "Allow"
resources = ["arn:aws:s3:::${var.access_logs_bucket}/${var.access_logs_prefix}/*"]

principals {
identifiers = ["${data.aws_elb_service_account.main.arn}"]
type = "AWS"
}
}

statement {
actions = [
"s3:PutObject"
]
effect = "Allow"
resources = ["arn:aws:s3:::${var.access_logs_bucket}/${var.access_logs_prefix}/*"]
principals {
identifiers = ["delivery.logs.amazonaws.com"]
type = "Service"
}
}


statement {
actions = [
"s3:GetBucketAcl"
]
effect = "Allow"
resources = ["arn:aws:s3:::${var.access_logs_bucket}"]
principals {
identifiers = ["delivery.logs.amazonaws.com"]
type = "Service"
}
}
}

resource "aws_s3_bucket_policy" "lb-logs" {
count = var.enable_access_logs ? 1 : 0

bucket = var.access_logs_bucket
policy = data.aws_iam_policy_document.lb_logs.json
}

resource "aws_alb_listener" "http" {
Expand Down
149 changes: 149 additions & 0 deletions aws/ecs/alb_waf.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
locals {
# 1-128 characters, a-z, A-Z, 0-9, and _ (underscore)
# unique within the scope of the resource
# i.e. unique per REGION if scope is REGIONAL
# unique per ACCOUNT if scope is CLOUDFRONT
waf_acl_name = "${var.project}-${var.environment}-alb-waf-acl"
}

resource "aws_wafv2_web_acl_association" "alb_waf" {
count = var.enable_waf ? 1 : 0

resource_arn = aws_alb.alb.arn
web_acl_arn = aws_wafv2_web_acl.alb[0].arn
}

resource "aws_wafv2_web_acl" "alb" {
count = var.enable_waf ? 1 : 0

name = local.waf_acl_name
scope = "REGIONAL" # or "CLOUDFRONT", but we have 1 ALB per cluster

default_action {
block {}
}

dynamic "rule" {
for_each = var.waf_rules
content {
name = rule.value.name
priority = rule.value.priority + 1 # prio must be unqiue. Hardcoded rules: see below

dynamic "action" {
for_each = rule.value.name != "AWSManagedRulesCommonRuleSet" ? [1] : []

content {
dynamic "allow" {
for_each = rule.value.action_type == "ALLOW" ? [1] : []
content {}
}

dynamic "block" {
for_each = rule.value.action_type == "BLOCK" ? [1] : []
content {}
}

dynamic "count" {
for_each = rule.value.action_type == "COUNT" ? [1] : []
content {}
}
}
}

dynamic "override_action" {
# TODO: We should match the rule set name here
for_each = rule.value.name == "AWSManagedRulesCommonRuleSet" ? [1] : []

content {
dynamic "none" {
for_each = rule.value.action_type == "NONE" ? [1] : []
content {}
}
dynamic "count" {
for_each = rule.value.action_type == "COUNT" ? [1] : []
content {}
}
}
}

statement {
dynamic "byte_match_statement" {
for_each = rule.value.header_name != null ? [1] : []
content {
search_string = rule.value.header_value
field_to_match {
single_header {
name = rule.value.header_name
}
}
text_transformation {
priority = 0
type = rule.value.text_transformation
}
positional_constraint = rule.value.positional_constraint
}
}

dynamic "managed_rule_group_statement" {
for_each = rule.value.name == "AWSManagedRulesCommonRuleSet" ? [1] : []
content {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
}

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = rule.value.name
sampled_requests_enabled = true
}
}
}

dynamic "rule" {
for_each = [1]
content {
name = "RequireSpecificHost"
priority = 1 # Adjust the priority accordingly

action {
allow {}
}

statement {
byte_match_statement {
search_string = var.domain_name
field_to_match {
single_header {
name = "host"
}
}
text_transformation {
priority = 0
type = "NONE"
}
positional_constraint = "ENDS_WITH"
}
}

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

visibility_config {
cloudwatch_metrics_enabled = true
metric_name = local.waf_acl_name
sampled_requests_enabled = true
}

tags = {
Name = local.waf_acl_name
Environment = var.environment
Project = var.project
}
}
56 changes: 56 additions & 0 deletions aws/ecs/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,59 @@ variable "alb_subnet_ids" {
variable "nlb_subnet_ids" {
type = list(string)
}

# WAF & Access Logs
variable "enable_waf" {
description = "Enable WAF for the ALB"
type = bool
default = false
}

variable "domain_name" {
description = "Allowlisted domain name for the WAF"
type = string
default = ""

}

variable "waf_rules" {
description = "List of WAF rules to include in the Web ACL"
type = list(object({
name = string
priority = number
action_type = string # one of: ALLOW, BLOCK, COUNT
header_name = optional(string)
header_value = optional(string)
positional_constraint = optional(string, "EXACTLY")
text_transformation = optional(string, "NONE")
}))
default = [
{
name = "AWSManagedRulesCommonRuleSet"
priority = 1
action_type = "COUNT"
header_name = null
header_value = null
positional_constraint = "EXACTLY"
text_transformation = "NONE"
}
]
}

variable "enable_access_logs" {
description = "Enable access logging for the ALB"
type = bool
default = false
}

variable "access_logs_bucket" {
description = "S3 bucket for ALB access logs"
type = string
default = ""
}

variable "access_logs_prefix" {
description = "S3 prefix for ALB access logs"
type = string
default = "lb-logs"
}

0 comments on commit 5d24f0c

Please sign in to comment.