Skip to content

Commit cccfb6a

Browse files
committed
feat(terraform): add the Azure waf module
Mirrors deployment/terraform/modules/aws/waf. The policy is regional, the same scope the AWS web ACL uses, and attaches to an Application Gateway or a Front Door route. The rule inventory collapses. Where AWS composes four managed rule groups, the OWASP Core Rule Set covers the common, known-bad-inputs and SQL injection groups on its own, and the Microsoft bot manager set stands in for the anonymous IP list. Two rate limits and the optional allowlist and geo block stay as custom rules, counting per client address over five minutes as before. Three differences to know when reading this against the AWS module: - Detection mode replaces overriding every managed rule to COUNT, and is the way to see what a new policy would do before it does it. Individual rules still have overrides, but Azure identifies them by group and numeric id rather than by name. - Rate limit exemptions are a second, negated match condition rather than a scope-down statement. Conditions on a rule are combined with AND, so the effect is the same. - There is no log group here. Azure emits WAF logs from the Application Gateway or Front Door the policy attaches to, so the diagnostic setting belongs on that resource rather than on the policy.
1 parent 1aa33ff commit cccfb6a

5 files changed

Lines changed: 534 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
locals {
2+
ip_allowlist_enabled = length(var.allowed_ip_cidrs) > 0
3+
rate_limit_exempt_enabled = length(var.rate_limit_exempt_ip_cidrs) > 0
4+
geo_restriction_enabled = length(var.geo_restriction_countries) > 0
5+
6+
managed_rule_sets = concat(
7+
[{ type = "OWASP", version = var.owasp_rule_set_version }],
8+
var.enable_bot_protection ? [{ type = "Microsoft_BotManagerRuleSet", version = var.bot_manager_rule_set_version }] : [],
9+
)
10+
11+
# The overrides arrive as a flat list but the provider nests them by rule set
12+
# and then by rule group, so regroup them once here.
13+
overrides_by_set = {
14+
for set_type in distinct([for o in var.managed_rule_overrides : o.rule_set_type]) :
15+
set_type => {
16+
for group_name in distinct([for o in var.managed_rule_overrides : o.rule_group_name if o.rule_set_type == set_type]) :
17+
group_name => [for o in var.managed_rule_overrides : o if o.rule_set_type == set_type && o.rule_group_name == group_name]
18+
}
19+
}
20+
}
21+
22+
resource "azurerm_web_application_firewall_policy" "this" {
23+
name = "${var.name}-waf"
24+
resource_group_name = var.resource_group_name
25+
location = var.location
26+
tags = var.tags
27+
28+
policy_settings {
29+
enabled = true
30+
mode = var.mode
31+
request_body_check = true
32+
max_request_body_size_in_kb = var.max_request_body_size_in_kb
33+
file_upload_limit_in_mb = var.file_upload_limit_in_mb
34+
}
35+
36+
managed_rules {
37+
dynamic "managed_rule_set" {
38+
for_each = local.managed_rule_sets
39+
content {
40+
type = managed_rule_set.value.type
41+
version = managed_rule_set.value.version
42+
43+
dynamic "rule_group_override" {
44+
for_each = try(local.overrides_by_set[managed_rule_set.value.type], {})
45+
content {
46+
rule_group_name = rule_group_override.key
47+
48+
dynamic "rule" {
49+
for_each = rule_group_override.value
50+
content {
51+
id = rule.value.rule_id
52+
action = rule.value.action
53+
enabled = rule.value.enabled
54+
}
55+
}
56+
}
57+
}
58+
}
59+
}
60+
}
61+
62+
# Anything not on the allowlist is refused before the managed rules run.
63+
dynamic "custom_rules" {
64+
for_each = local.ip_allowlist_enabled ? [1] : []
65+
content {
66+
name = "BlockRequestsOutsideAllowedIPs"
67+
priority = 1
68+
rule_type = "MatchRule"
69+
action = "Block"
70+
71+
match_conditions {
72+
match_variables {
73+
variable_name = "RemoteAddr"
74+
}
75+
operator = "IPMatch"
76+
negation_condition = true
77+
match_values = var.allowed_ip_cidrs
78+
}
79+
}
80+
}
81+
82+
dynamic "custom_rules" {
83+
for_each = local.geo_restriction_enabled ? [1] : []
84+
content {
85+
name = "BlockRestrictedCountries"
86+
priority = 10
87+
rule_type = "MatchRule"
88+
action = "Block"
89+
90+
match_conditions {
91+
match_variables {
92+
variable_name = "RemoteAddr"
93+
}
94+
operator = "GeoMatch"
95+
match_values = var.geo_restriction_countries
96+
}
97+
}
98+
}
99+
100+
# Match conditions on a rule are combined with AND, so the negated exempt
101+
# list is what keeps the limit from applying to those addresses.
102+
custom_rules {
103+
name = "ApiRateLimit"
104+
priority = 20
105+
rule_type = "RateLimitRule"
106+
action = "Block"
107+
rate_limit_duration = "FiveMins"
108+
rate_limit_threshold = var.api_rate_limit_requests_per_5_minutes
109+
group_rate_limit_by = "ClientAddr"
110+
111+
match_conditions {
112+
match_variables {
113+
variable_name = "RequestUri"
114+
}
115+
operator = "BeginsWith"
116+
match_values = [var.api_path_prefix]
117+
}
118+
119+
dynamic "match_conditions" {
120+
for_each = local.rate_limit_exempt_enabled ? [1] : []
121+
content {
122+
match_variables {
123+
variable_name = "RemoteAddr"
124+
}
125+
operator = "IPMatch"
126+
negation_condition = true
127+
match_values = var.rate_limit_exempt_ip_cidrs
128+
}
129+
}
130+
}
131+
132+
custom_rules {
133+
name = "GlobalRateLimit"
134+
priority = 30
135+
rule_type = "RateLimitRule"
136+
action = "Block"
137+
rate_limit_duration = "FiveMins"
138+
rate_limit_threshold = var.rate_limit_requests_per_5_minutes
139+
group_rate_limit_by = "ClientAddr"
140+
141+
match_conditions {
142+
match_variables {
143+
variable_name = "RemoteAddr"
144+
}
145+
operator = "IPMatch"
146+
match_values = ["0.0.0.0/0", "::/0"]
147+
}
148+
149+
dynamic "match_conditions" {
150+
for_each = local.rate_limit_exempt_enabled ? [1] : []
151+
content {
152+
match_variables {
153+
variable_name = "RemoteAddr"
154+
}
155+
operator = "IPMatch"
156+
negation_condition = true
157+
match_values = var.rate_limit_exempt_ip_cidrs
158+
}
159+
}
160+
}
161+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
output "policy_id" {
2+
description = "Resource ID of the WAF policy. Attach it to an Application Gateway, or to a Front Door route."
3+
value = azurerm_web_application_firewall_policy.this.id
4+
}
5+
6+
output "policy_name" {
7+
description = "Name of the WAF policy"
8+
value = azurerm_web_application_firewall_policy.this.name
9+
}
10+
11+
# Unlike the AWS module there is no log group here. Azure emits WAF logs from
12+
# the Application Gateway or Front Door the policy is attached to, so the
13+
# diagnostic setting belongs on that resource.
14+
output "mode" {
15+
description = "Whether the policy blocks matches or only logs them"
16+
value = var.mode
17+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Plans the module against a mocked provider, so these run without an Azure
2+
# subscription or credentials. Run with `terraform test` from the module directory.
3+
4+
mock_provider "azurerm" {}
5+
6+
variables {
7+
name = "onyx"
8+
resource_group_name = "onyx-rg"
9+
location = "eastus"
10+
}
11+
12+
run "defaults_block_and_carry_both_rule_sets" {
13+
command = plan
14+
15+
assert {
16+
condition = one(azurerm_web_application_firewall_policy.this.policy_settings).mode == "Prevention"
17+
error_message = "The policy should block what it matches by default."
18+
}
19+
20+
assert {
21+
condition = length(one(azurerm_web_application_firewall_policy.this.managed_rules).managed_rule_set) == 2
22+
error_message = "OWASP plus the bot manager set should both be present by default."
23+
}
24+
25+
assert {
26+
condition = one(azurerm_web_application_firewall_policy.this.managed_rules).managed_rule_set[0].type == "OWASP"
27+
error_message = "The OWASP set covers what the AWS module gets from its common, known-bad-inputs and SQLi groups."
28+
}
29+
}
30+
31+
run "only_the_two_rate_limits_exist_by_default" {
32+
command = plan
33+
34+
assert {
35+
condition = length(azurerm_web_application_firewall_policy.this.custom_rules) == 2
36+
error_message = "With no allowlist and no geo blocking, only the two rate limits should exist."
37+
}
38+
39+
assert {
40+
condition = alltrue([
41+
for r in azurerm_web_application_firewall_policy.this.custom_rules :
42+
r.rate_limit_duration == "FiveMins"
43+
])
44+
error_message = "The AWS module counts requests per five minutes, so these should too."
45+
}
46+
47+
assert {
48+
condition = alltrue([
49+
for r in azurerm_web_application_firewall_policy.this.custom_rules :
50+
r.group_rate_limit_by == "ClientAddr"
51+
])
52+
error_message = "Rate limits should count per client address, matching the AWS aggregate key of IP."
53+
}
54+
}
55+
56+
run "an_allowlist_blocks_everything_outside_it_first" {
57+
command = plan
58+
59+
variables {
60+
allowed_ip_cidrs = ["203.0.113.0/24"]
61+
}
62+
63+
assert {
64+
condition = length(azurerm_web_application_firewall_policy.this.custom_rules) == 3
65+
error_message = "The allowlist rule should be added to the two rate limits."
66+
}
67+
68+
assert {
69+
condition = one([
70+
for r in azurerm_web_application_firewall_policy.this.custom_rules :
71+
r.priority if r.name == "BlockRequestsOutsideAllowedIPs"
72+
]) == 1
73+
error_message = "The allowlist must be evaluated before anything else."
74+
}
75+
76+
assert {
77+
condition = one([
78+
for r in azurerm_web_application_firewall_policy.this.custom_rules :
79+
r.match_conditions[0].negation_condition if r.name == "BlockRequestsOutsideAllowedIPs"
80+
]) == true
81+
error_message = "The rule blocks addresses that are NOT on the list, so the condition has to be negated."
82+
}
83+
}
84+
85+
run "exempt_ranges_add_a_negated_condition_to_each_limit" {
86+
command = plan
87+
88+
variables {
89+
rate_limit_exempt_ip_cidrs = ["203.0.113.0/24"]
90+
}
91+
92+
assert {
93+
condition = alltrue([
94+
for r in azurerm_web_application_firewall_policy.this.custom_rules :
95+
length(r.match_conditions) == 2 if r.rule_type == "RateLimitRule"
96+
])
97+
error_message = "Conditions are combined with AND, so the exemption is a second, negated condition on each limit."
98+
}
99+
}
100+
101+
run "geo_blocking_adds_a_rule" {
102+
command = plan
103+
104+
variables {
105+
geo_restriction_countries = ["KP"]
106+
}
107+
108+
assert {
109+
condition = one([
110+
for r in azurerm_web_application_firewall_policy.this.custom_rules :
111+
r.match_conditions[0].operator if r.name == "BlockRestrictedCountries"
112+
]) == "GeoMatch"
113+
error_message = "Country blocking uses the GeoMatch operator."
114+
}
115+
}
116+
117+
run "detection_mode_stops_blocking" {
118+
command = plan
119+
120+
variables {
121+
mode = "Detection"
122+
}
123+
124+
assert {
125+
condition = one(azurerm_web_application_firewall_policy.this.policy_settings).mode == "Detection"
126+
error_message = "Detection is the whole-policy equivalent of overriding every AWS managed rule to COUNT."
127+
}
128+
}
129+
130+
run "bot_protection_can_be_dropped" {
131+
command = plan
132+
133+
variables {
134+
enable_bot_protection = false
135+
}
136+
137+
assert {
138+
condition = length(one(azurerm_web_application_firewall_policy.this.managed_rules).managed_rule_set) == 1
139+
error_message = "Turning off bot protection should leave only the OWASP set."
140+
}
141+
}
142+
143+
run "overrides_are_regrouped_under_their_rule_set" {
144+
command = plan
145+
146+
variables {
147+
managed_rule_overrides = [
148+
{ rule_group_name = "REQUEST-942-APPLICATION-ATTACK-SQLI", rule_id = "942100", action = "Log" },
149+
{ rule_group_name = "REQUEST-942-APPLICATION-ATTACK-SQLI", rule_id = "942200", action = "Log" },
150+
{ rule_group_name = "REQUEST-920-PROTOCOL-ENFORCEMENT", rule_id = "920300", enabled = false },
151+
]
152+
}
153+
154+
assert {
155+
condition = length(one(azurerm_web_application_firewall_policy.this.managed_rules).managed_rule_set[0].rule_group_override) == 2
156+
error_message = "Three overrides across two groups should nest into two group overrides."
157+
}
158+
159+
assert {
160+
condition = length(one([
161+
for g in one(azurerm_web_application_firewall_policy.this.managed_rules).managed_rule_set[0].rule_group_override :
162+
g.rule if g.rule_group_name == "REQUEST-942-APPLICATION-ATTACK-SQLI"
163+
])) == 2
164+
error_message = "Both SQLi overrides should land in the same group."
165+
}
166+
}
167+
168+
run "rejects_an_action_azure_does_not_have" {
169+
command = plan
170+
171+
variables {
172+
managed_rule_overrides = [
173+
{ rule_group_name = "REQUEST-942-APPLICATION-ATTACK-SQLI", rule_id = "942100", action = "Count" },
174+
]
175+
}
176+
177+
expect_failures = [var.managed_rule_overrides]
178+
}
179+
180+
run "rejects_a_country_code_that_is_not_one" {
181+
command = plan
182+
183+
variables {
184+
geo_restriction_countries = ["Korea"]
185+
}
186+
187+
expect_failures = [var.geo_restriction_countries]
188+
}
189+
190+
run "rejects_an_upload_limit_azure_would_reject" {
191+
command = plan
192+
193+
variables {
194+
file_upload_limit_in_mb = 5000
195+
}
196+
197+
expect_failures = [var.file_upload_limit_in_mb]
198+
}

0 commit comments

Comments
 (0)