I'm setting up a WAF for an API. I want to have different rate limits depending on the source of the traffic. Some traffic comes from Vercel and want to have a higher threshold compared to the traffic that comes directly. What I see is that when a request arrives, it's checked against the Vercel rate limiter rule, but it then falls back to the default rule.
At high level, what I want to implement is:
- A rate limit if a request has a header with a given value.
- A default rate limit if the one above didn't match (the header is missing).
Here's the terraform config of the ACL (it's slighly simplified from the original one)
resource "aws_wafv2_web_acl" "api" {
name = "waf"
description = "WAF for ALB"
scope = "REGIONAL"
default_action {
allow {}
}
rule {
name = "Vercel"
priority = 1
action {
block {}
}
statement {
rate_based_statement {
limit = 5000
evaluation_window_sec = 300
aggregate_key_type = "FORWARDED_IP"
forwarded_ip_config {
header_name = "x-forwarded-for"
fallback_behavior = "NO_MATCH"
}
scope_down_statement {
byte_match_statement {
field_to_match {
single_header {
name = "user-agent"
}
}
positional_constraint = "EXACTLY"
search_string = "Vercel Edge Functions"
text_transformation {
priority = 0
type = "NONE"
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "vercel"
sampled_requests_enabled = true
}
}
Default limiting rule for traffic we cannot match
rule {
name = "default-rate-limit"
priority = 2
action {
block {}
}
statement {
rate_based_statement {
limit = 60
evaluation_window_sec = 60
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "default-rate-limit"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "waf"
sampled_requests_enabled = true
}
}
To test this, I wrote a script that sends a bunch of requests with no headers until it's blocked. Then, I run a single request with the user-agent and x-forwarded-for headers and it generated this log snippet. The Vercel rule matched, but the processing didn't stop there and continue to the default rate limit rule.
"terminatingRuleId": "default-rate-limit",
"terminatingRuleType": "RATE_BASED",
"action": "BLOCK",
"terminatingRuleMatchDetails": [],
"httpSourceName": "ALB",
"httpSourceId": "xxxxxxxxx-app/api-lb/xxxxxxxxxxxx",
"ruleGroupList": [],
"rateBasedRuleList": [
{
"rateBasedRuleId": "arn:aws:wafv2:us-west-2:xxxxxxxxxx",
"rateBasedRuleName": "Vercel",
"limitKey": "FORWARDEDIP",
"maxRateAllowed": 50000,
"evaluationWindowSec": 300,
"limitValue": "yyy.yyy.yyy.yyy". // IP from x-forwarded-for header
},
{
"rateBasedRuleId": "arn:aws:wafv2:xxxxxxxxxx",
"rateBasedRuleName": "default-rate-limit",
"limitKey": "IP",
"maxRateAllowed": 60,
"evaluationWindowSec": 60,
"limitValue": "xxx.xxx.xxx.xxx" // IP from where the request originated
}
],
I'm wondering if something is misconfigured above or if I need to use labels for this use case.
And a note that I'm using the user-agent while I test this. The real solution will use a different header with a value that is more difficult to guess.
edit
I found a potential (ugly) solution. To have a rate limiter and immediately after a rule that allows traffic with the same conditions as in the scope down statement of the rate limiter.
I tried using labels, but the WAF only applies the label in a rate limit rule when it's actively blocking traffic. So it's not possible to know if a rule didn't match the scope down statement or matched but was not rate limited.