Skip to content

Commit c9657a0

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 26830f6 commit c9657a0

5 files changed

Lines changed: 660 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. Front Door uses a different resource, azurerm_cdn_frontdoor_firewall_policy, and cannot take this one."
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 the policy is attached to, so the diagnostic setting
13+
# belongs on that resource.
14+
output "mode" {
15+
description = "Whether the policy blocks matches or only logs them"
16+
value = var.mode
17+
}

0 commit comments

Comments
 (0)