Skip to content

Commit 123226c

Browse files
OlamideOl1claude
andcommitted
Add host_uri_rate_limit_rules to WAF module
Per-IP rate limiting scoped to a Host and optional URI path(s), with a per-rule EXACTLY/STARTS_WITH match type and configurable limit and evaluation window. Defaults to {}, so existing consumers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fe674ae commit 123226c

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

aws/waf/main.tf

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,144 @@ resource "aws_wafv2_web_acl" "main" {
351351
}
352352
}
353353

354+
dynamic "rule" {
355+
for_each = var.host_uri_rate_limit_rules
356+
content {
357+
name = "${rule.value["name"]}-host-uri-ratelimit"
358+
priority = rule.value["priority"]
359+
360+
dynamic "action" {
361+
for_each = rule.value["count_override"] == true ? [1] : []
362+
content {
363+
count {}
364+
}
365+
}
366+
dynamic "action" {
367+
for_each = rule.value["count_override"] == false ? [1] : []
368+
content {
369+
block {}
370+
}
371+
}
372+
373+
statement {
374+
rate_based_statement {
375+
limit = rule.value["limit"]
376+
aggregate_key_type = "IP"
377+
evaluation_window_sec = rule.value["evaluation_window_sec"]
378+
379+
scope_down_statement {
380+
# Whole host (no uri_paths): scope down to the Host header only.
381+
dynamic "byte_match_statement" {
382+
for_each = length(rule.value["uri_paths"]) == 0 ? [1] : []
383+
content {
384+
field_to_match {
385+
single_header {
386+
name = "host"
387+
}
388+
}
389+
390+
positional_constraint = "EXACTLY"
391+
392+
search_string = lower(rule.value["host"])
393+
394+
text_transformation {
395+
priority = 0
396+
type = "LOWERCASE"
397+
}
398+
}
399+
}
400+
401+
# Host + URI: scope down to Host AND one of the given paths.
402+
dynamic "and_statement" {
403+
for_each = length(rule.value["uri_paths"]) > 0 ? [1] : []
404+
content {
405+
statement {
406+
byte_match_statement {
407+
field_to_match {
408+
single_header {
409+
name = "host"
410+
}
411+
}
412+
413+
positional_constraint = "EXACTLY"
414+
415+
search_string = lower(rule.value["host"])
416+
417+
text_transformation {
418+
priority = 0
419+
type = "LOWERCASE"
420+
}
421+
}
422+
}
423+
424+
dynamic "statement" {
425+
for_each = length(rule.value["uri_paths"]) == 1 ? [1] : []
426+
content {
427+
byte_match_statement {
428+
field_to_match {
429+
uri_path {}
430+
}
431+
432+
positional_constraint = rule.value["uri_match_type"]
433+
434+
search_string = lower(rule.value["uri_paths"][0])
435+
436+
text_transformation {
437+
priority = 0
438+
type = "URL_DECODE"
439+
}
440+
text_transformation {
441+
priority = 1
442+
type = "LOWERCASE"
443+
}
444+
}
445+
}
446+
}
447+
448+
dynamic "statement" {
449+
for_each = length(rule.value["uri_paths"]) > 1 ? [1] : []
450+
content {
451+
or_statement {
452+
dynamic "statement" {
453+
for_each = rule.value["uri_paths"]
454+
content {
455+
byte_match_statement {
456+
field_to_match {
457+
uri_path {}
458+
}
459+
460+
positional_constraint = rule.value["uri_match_type"]
461+
462+
search_string = lower(statement.value)
463+
464+
text_transformation {
465+
priority = 0
466+
type = "URL_DECODE"
467+
}
468+
text_transformation {
469+
priority = 1
470+
type = "LOWERCASE"
471+
}
472+
}
473+
}
474+
}
475+
}
476+
}
477+
}
478+
}
479+
}
480+
}
481+
}
482+
}
483+
484+
visibility_config {
485+
cloudwatch_metrics_enabled = true
486+
sampled_requests_enabled = true
487+
metric_name = "${rule.value["name"]}-host-uri-ratelimit"
488+
}
489+
}
490+
}
491+
354492
dynamic "rule" {
355493
for_each = var.aws_managed_rule_groups
356494
content {

aws/waf/variables.tf

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,27 @@ variable "host_ip_restriction_rules" {
8383
}))
8484
default = {}
8585
}
86+
87+
variable "host_uri_rate_limit_rules" {
88+
description = "Per-IP rate limits scoped to a specific Host and optional URI path(s). Only requests matching the host (and URI, if given) count toward the limit; when an IP exceeds it within the evaluation window the rule action applies."
89+
type = map(object({
90+
name = string # Friendly name -> rule name + CloudWatch metric.
91+
priority = number # Unique WAF rule priority within the ACL.
92+
limit = optional(number, 2000) # Max matching requests per IP per evaluation window (AWS minimum is 10).
93+
evaluation_window_sec = optional(number, 300) # Rate-limit window in seconds. One of 60, 120, 300, 600.
94+
host = string # Exact Host header to scope the rate limit to, e.g. "example.com".
95+
uri_paths = optional(list(string), []) # URI path(s) to scope the rate limit to. Empty (default) rate-limits the whole host.
96+
uri_match_type = optional(string, "STARTS_WITH") # How to match uri_paths: EXACTLY (pin one endpoint) or STARTS_WITH (prefix).
97+
count_override = optional(bool, false) # If true, override the action to `count` (dry run). If false (default), the action is `block` when the limit is exceeded.
98+
}))
99+
default = {}
100+
101+
validation {
102+
condition = alltrue([for r in values(var.host_uri_rate_limit_rules) : contains(["EXACTLY", "STARTS_WITH"], r.uri_match_type)])
103+
error_message = "uri_match_type must be EXACTLY or STARTS_WITH."
104+
}
105+
validation {
106+
condition = alltrue([for r in values(var.host_uri_rate_limit_rules) : contains([60, 120, 300, 600], r.evaluation_window_sec)])
107+
error_message = "evaluation_window_sec must be one of 60, 120, 300, 600."
108+
}
109+
}

0 commit comments

Comments
 (0)