diff --git a/aws/ecs/README.md b/aws/ecs/README.md index 9eb0c426..ef4a83d5 100644 --- a/aws/ecs/README.md +++ b/aws/ecs/README.md @@ -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 @@ -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" } ``` diff --git a/aws/ecs/alb.tf b/aws/ecs/alb.tf index 017af850..f278f052 100644 --- a/aws/ecs/alb.tf +++ b/aws/ecs/alb.tf @@ -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" { diff --git a/aws/ecs/alb_waf.tf b/aws/ecs/alb_waf.tf new file mode 100644 index 00000000..000e7072 --- /dev/null +++ b/aws/ecs/alb_waf.tf @@ -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 + } +} diff --git a/aws/ecs/variables.tf b/aws/ecs/variables.tf index 9a26d95e..3341650a 100644 --- a/aws/ecs/variables.tf +++ b/aws/ecs/variables.tf @@ -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" +}