Skip to content

Commit 3476cbd

Browse files
OlamideOl1claude
andcommitted
Add REGEX match type to host_uri_rate_limit_rules
Lets a rate-limit rule target URI paths with variable segments (e.g. /api/v1/users/<id>/validation) via WAF regex_match_statement. Regex patterns are URL-decoded but not lowercased, so character classes and anchors are preserved; EXACTLY/STARTS_WITH byte matching is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 123226c commit 3476cbd

2 files changed

Lines changed: 67 additions & 31 deletions

File tree

aws/waf/main.tf

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -424,22 +424,40 @@ resource "aws_wafv2_web_acl" "main" {
424424
dynamic "statement" {
425425
for_each = length(rule.value["uri_paths"]) == 1 ? [1] : []
426426
content {
427-
byte_match_statement {
428-
field_to_match {
429-
uri_path {}
430-
}
427+
dynamic "byte_match_statement" {
428+
for_each = rule.value["uri_match_type"] == "REGEX" ? [] : [1]
429+
content {
430+
field_to_match {
431+
uri_path {}
432+
}
431433

432-
positional_constraint = rule.value["uri_match_type"]
434+
positional_constraint = rule.value["uri_match_type"]
433435

434-
search_string = lower(rule.value["uri_paths"][0])
436+
search_string = lower(rule.value["uri_paths"][0])
435437

436-
text_transformation {
437-
priority = 0
438-
type = "URL_DECODE"
438+
text_transformation {
439+
priority = 0
440+
type = "URL_DECODE"
441+
}
442+
text_transformation {
443+
priority = 1
444+
type = "LOWERCASE"
445+
}
439446
}
440-
text_transformation {
441-
priority = 1
442-
type = "LOWERCASE"
447+
}
448+
dynamic "regex_match_statement" {
449+
for_each = rule.value["uri_match_type"] == "REGEX" ? [1] : []
450+
content {
451+
field_to_match {
452+
uri_path {}
453+
}
454+
455+
regex_string = rule.value["uri_paths"][0]
456+
457+
text_transformation {
458+
priority = 0
459+
type = "URL_DECODE"
460+
}
443461
}
444462
}
445463
}
@@ -452,22 +470,40 @@ resource "aws_wafv2_web_acl" "main" {
452470
dynamic "statement" {
453471
for_each = rule.value["uri_paths"]
454472
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"
473+
dynamic "byte_match_statement" {
474+
for_each = rule.value["uri_match_type"] == "REGEX" ? [] : [1]
475+
content {
476+
field_to_match {
477+
uri_path {}
478+
}
479+
480+
positional_constraint = rule.value["uri_match_type"]
481+
482+
search_string = lower(statement.value)
483+
484+
text_transformation {
485+
priority = 0
486+
type = "URL_DECODE"
487+
}
488+
text_transformation {
489+
priority = 1
490+
type = "LOWERCASE"
491+
}
467492
}
468-
text_transformation {
469-
priority = 1
470-
type = "LOWERCASE"
493+
}
494+
dynamic "regex_match_statement" {
495+
for_each = rule.value["uri_match_type"] == "REGEX" ? [1] : []
496+
content {
497+
field_to_match {
498+
uri_path {}
499+
}
500+
501+
regex_string = statement.value
502+
503+
text_transformation {
504+
priority = 0
505+
type = "URL_DECODE"
506+
}
471507
}
472508
}
473509
}

aws/waf/variables.tf

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,15 @@ variable "host_uri_rate_limit_rules" {
9292
limit = optional(number, 2000) # Max matching requests per IP per evaluation window (AWS minimum is 10).
9393
evaluation_window_sec = optional(number, 300) # Rate-limit window in seconds. One of 60, 120, 300, 600.
9494
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).
95+
uri_paths = optional(list(string), []) # URI path(s) to scope the rate limit to. With REGEX these are regex patterns. Empty (default) rate-limits the whole host.
96+
uri_match_type = optional(string, "STARTS_WITH") # How to match uri_paths: EXACTLY (pin one endpoint), STARTS_WITH (prefix), or REGEX (for variable segments, e.g. "^/api/v1/users/[^/]+/validation$"). REGEX is case-sensitive (URL-decoded, not lowercased); use an inline (?i) flag for case-insensitivity.
9797
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.
9898
}))
9999
default = {}
100100

101101
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."
102+
condition = alltrue([for r in values(var.host_uri_rate_limit_rules) : contains(["EXACTLY", "STARTS_WITH", "REGEX"], r.uri_match_type)])
103+
error_message = "uri_match_type must be EXACTLY, STARTS_WITH, or REGEX."
104104
}
105105
validation {
106106
condition = alltrue([for r in values(var.host_uri_rate_limit_rules) : contains([60, 120, 300, 600], r.evaluation_window_sec)])

0 commit comments

Comments
 (0)