Skip to content

Commit 1a98623

Browse files
author
zac
committed
Add WAF rate limiting rule.
1 parent 8455a5b commit 1a98623

File tree

5 files changed

+342
-0
lines changed

5 files changed

+342
-0
lines changed

waf_rate_limit/__init__.py

Whitespace-only changes.

waf_rate_limit/cr_response.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import logging
2+
from urllib.request import urlopen, Request, HTTPError, URLError
3+
import json
4+
5+
logger = logging.getLogger()
6+
logger.setLevel(logging.INFO)
7+
8+
class CustomResourceResponse:
9+
def __init__(self, request_payload):
10+
self.payload = request_payload
11+
self.response = {
12+
"StackId": request_payload["StackId"],
13+
"RequestId": request_payload["RequestId"],
14+
"LogicalResourceId": request_payload["LogicalResourceId"],
15+
"Status": 'SUCCESS',
16+
}
17+
18+
19+
def respond_error(self, message):
20+
self.response['Status'] = 'FAILED'
21+
self.response['Reason'] = message
22+
self.respond({})
23+
24+
def respond(self, data):
25+
event = self.payload
26+
response = self.response
27+
####
28+
#### copied from https://github.com/ryansb/cfn-wrapper-python/blob/master/cfn_resource.py
29+
####
30+
31+
if event.get("PhysicalResourceId", False):
32+
response["PhysicalResourceId"] = event["PhysicalResourceId"]
33+
34+
logger.debug("Received %s request with event: %s" %
35+
(event['RequestType'], json.dumps(event)))
36+
37+
response["Data"] = data
38+
39+
serialized = json.dumps(response)
40+
41+
logger.info(f"Responding to {event['RequestType']} request with: {serialized}")
42+
req_data = serialized.encode('utf-8')
43+
44+
req = Request(
45+
event['ResponseURL'],
46+
data=req_data,
47+
headers={'Content-Length': len(req_data), 'Content-Type': ''}
48+
)
49+
req.get_method = lambda: 'PUT'
50+
51+
try:
52+
urlopen(req)
53+
logger.debug("Request to CFN API succeeded, nothing to do here")
54+
except HTTPError as e:
55+
logger.error("Callback to CFN API failed with status %d" % e.code)
56+
logger.error("Response: %s" % e.reason)
57+
except URLError as e:
58+
logger.error("Failed to reach the server - %s" % e.reason)

waf_rate_limit/handler.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import sys
2+
import os
3+
4+
sys.path.append(f"{os.environ['LAMBDA_TASK_ROOT']}/lib")
5+
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
6+
7+
import cr_response
8+
from logic import WafRateLimit
9+
import json
10+
11+
def lambda_handler(event, context):
12+
13+
print(f"Received event:{json.dumps(event)}")
14+
15+
lambda_response = cr_response.CustomResourceResponse(event)
16+
cr_params = event['ResourceProperties']
17+
waf_logic = WafRateLimit(cr_params)
18+
try:
19+
# if create request, generate physical id, both for create/update copy files
20+
if event['RequestType'] == 'Create':
21+
event['PhysicalResourceId'] = waf_logic._create_rate_based_rule()
22+
data = {
23+
"RuleID" : event['PhysicalResourceId']
24+
}
25+
lambda_response.respond(data)
26+
27+
elif event['RequestType'] == 'Update':
28+
waf_logic._update_rate_based_rule(event['PhysicalResourceId'])
29+
data = {
30+
"RuleID" : event['PhysicalResourceId']
31+
}
32+
lambda_response.respond(data)
33+
34+
elif event['RequestType'] == 'Delete':
35+
print(event['PhysicalResourceId'])
36+
waf_logic._delete_rate_based_rule(event['PhysicalResourceId'])
37+
data = { }
38+
lambda_response.respond(data)
39+
40+
except Exception as e:
41+
message = str(e)
42+
lambda_response.respond_error(message)
43+
44+
return 'OK'

waf_rate_limit/logic.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import boto3
2+
import os
3+
import glob
4+
import logging
5+
6+
logger = logging.getLogger()
7+
logger.setLevel(logging.INFO)
8+
9+
10+
class WafRateLimit:
11+
12+
def __init__(self, resource_properties):
13+
self.rate = resource_properties['Rate']
14+
self.action = resource_properties['Action']
15+
self.region = resource_properties['Region']
16+
self.env = resource_properties['EnvironmentName']
17+
self.ip_set = resource_properties['IPSet']
18+
self.negated = resource_properties['Negated']
19+
self.region = resource_properties['Region']
20+
self.web_acl_id = resource_properties['WebACLId']
21+
self.priority = int(resource_properties['Priority'])
22+
self.rule_name = f"{resource_properties['EnvironmentName']}-rate-limit"
23+
self.ip_set_name = f"{resource_properties['EnvironmentName']}-rate-limit-ip-set"
24+
self.metric_name = self.rule_name.replace('-', '')
25+
26+
self.client = boto3.client('waf', region_name=self.region)
27+
28+
def retry(func):
29+
# Reattempt to execute a given function with optional arguments.
30+
# This is to avoid the insane error about a token already being expired.
31+
def wrapper(self, *args, **kwargs):
32+
attempts = 5
33+
remaining = attempts
34+
35+
while remaining:
36+
try:
37+
result = func(self, *args, **kwargs)
38+
return result
39+
except self.client.exceptions.WAFStaleDataException as e:
40+
logger.info(str(e))
41+
logger.info("(%d/%d) Retrying request with a new change token..." % (remaining + 1, attempts))
42+
remaining -= 1
43+
44+
logger.info("ERROR - failed to execute request.")
45+
exit(1)
46+
47+
return wrapper
48+
49+
def _create_rate_based_rule(self):
50+
rule_id = self.create_rate_based_rule()
51+
ip_set_id = self.create_ip_set()
52+
self.update_ip_set('INSERT', ip_set_id, self.ip_set)
53+
self.update_rate_based_rule('INSERT', ip_set_id, rule_id)
54+
self._add_to_web_acl(rule_id)
55+
56+
return rule_id
57+
58+
@retry
59+
def create_rate_based_rule(self):
60+
change_token = self._get_change_token()
61+
logger.info("Creating WAF rule '%s' ..." % self.rule_name)
62+
63+
rule_id = self.client.create_rate_based_rule(
64+
Name=self.rule_name,
65+
MetricName=self.metric_name,
66+
RateLimit=int(self.rate),
67+
RateKey='IP',
68+
ChangeToken=change_token
69+
)['Rule']['RuleId']
70+
71+
return rule_id
72+
73+
@retry
74+
def create_ip_set(self):
75+
change_token = self._get_change_token()
76+
logger.info("Creating IP set '%s' ..." % self.ip_set_name)
77+
78+
ip_set_id = self.client.create_ip_set(
79+
Name=self.ip_set_name,
80+
ChangeToken=change_token
81+
)['IPSet']['IPSetId']
82+
83+
return ip_set_id
84+
85+
@retry
86+
def update_ip_set(self, action, ip_set_id, ip_set):
87+
change_token = self._get_change_token()
88+
logger.info("Updating IP set '%s' (%s) with %d IPs as %s ..." % (self.ip_set_name, ip_set_id, len(self.ip_set), action))
89+
90+
self.client.update_ip_set(
91+
IPSetId=ip_set_id,
92+
ChangeToken=change_token,
93+
Updates=generate_waf_ip_set(action, ip_set)
94+
)
95+
96+
def _update_rate_based_rule(self, rule_id):
97+
self._delete_rate_based_rule(rule_id)
98+
return self._create_rate_based_rule()
99+
100+
@retry
101+
def update_rate_based_rule(self, action, ip_set_id, rule_id):
102+
change_token = self._get_change_token()
103+
logger.info("Updating rule '%s' (%s) with IP set '%s' (%s) as %s ..." % (self.rule_name, rule_id, self.ip_set_name, ip_set_id, action))
104+
105+
self.client.update_rate_based_rule(
106+
RuleId=rule_id,
107+
ChangeToken=change_token,
108+
Updates=[{
109+
'Action': action,
110+
'Predicate': {
111+
'Negated': to_bool(self.negated),
112+
'Type': 'IPMatch',
113+
'DataId': ip_set_id
114+
}
115+
}],
116+
RateLimit=int(self.rate)
117+
)
118+
119+
def _delete_rate_based_rule(self, rule_id):
120+
logger.info("Getting IP set for rule '%s' (%s) ..." % (self.rule_name, rule_id))
121+
122+
try:
123+
predicates = self.client.get_rate_based_rule(
124+
RuleId=rule_id
125+
)['Rule']['MatchPredicates']
126+
except self.client.exceptions.WAFNonexistentItemException as e:
127+
logger.info("%s: rule ID '%s' does not exist. Returning success" % (str(e), rule_id))
128+
return
129+
130+
if len(predicates):
131+
ip_set_id = predicates[0]['DataId']
132+
133+
logger.info("Getting IPs for IP set '%s' ..." % (ip_set_id))
134+
135+
current_ip_set = self.client.get_ip_set(
136+
IPSetId=ip_set_id
137+
)['IPSet']['IPSetDescriptors']
138+
139+
if len(current_ip_set):
140+
self.update_ip_set('DELETE', ip_set_id, current_ip_set)
141+
142+
self.update_rate_based_rule('DELETE', ip_set_id, rule_id)
143+
self.delete_ip_set(ip_set_id)
144+
145+
self._delete_from_web_acl(rule_id)
146+
self.delete_rate_based_rule(rule_id)
147+
148+
@retry
149+
def delete_ip_set(self, ip_set_id):
150+
change_token = self._get_change_token()
151+
logger.info("Deleting IP set '%s' ..." % (ip_set_id))
152+
153+
self.client.delete_ip_set(
154+
IPSetId=ip_set_id,
155+
ChangeToken=change_token
156+
)
157+
158+
@retry
159+
def delete_rate_based_rule(self, rule_id):
160+
change_token = self._get_change_token()
161+
logger.info("Deleting rule '%s' (%s) ..." % (self.rule_name, rule_id))
162+
163+
self.client.delete_rate_based_rule(
164+
RuleId=rule_id,
165+
ChangeToken=change_token
166+
)
167+
168+
def _get_change_token(self):
169+
token = self.client.get_change_token()['ChangeToken']
170+
logger.info("Got change token: %s" % token)
171+
return token
172+
173+
def _add_to_web_acl(self, rule_id):
174+
self._update_web_acl('INSERT', self.action, self.priority, rule_id)
175+
176+
def _delete_from_web_acl(self, rule_id):
177+
# Get the current rule priority, as it is needed in the update request
178+
web_acl_rules = self.client.get_web_acl(
179+
WebACLId=self.web_acl_id
180+
)['WebACL']['Rules']
181+
182+
current_rule = list(filter(lambda rule: rule['RuleId'] == rule_id, web_acl_rules))[0]
183+
current_action = current_rule['Action']['Type']
184+
current_priority = int(current_rule['Priority'])
185+
186+
self._update_web_acl('DELETE', current_action, current_priority, rule_id)
187+
188+
@retry
189+
def _update_web_acl(self, new_action, current_action, priority, rule_id):
190+
"""Add a rule ID with a web ACL.
191+
"""
192+
change_token = self._get_change_token()
193+
logger.info("%sing rule '%s' (%s) in web ACL ID '%s'" % (new_action, self.rule_name, rule_id, self.web_acl_id))
194+
195+
self.client.update_web_acl(
196+
WebACLId=self.web_acl_id,
197+
Updates=[{
198+
"Action": new_action,
199+
"ActivatedRule": {
200+
"Action": {
201+
"Type": current_action
202+
},
203+
"Priority": priority,
204+
"RuleId": rule_id,
205+
"Type": "RATE_BASED"
206+
}
207+
}],
208+
ChangeToken=change_token
209+
)
210+
211+
def generate_waf_ip_set(action, ips):
212+
return [{'Action': action, 'IPSetDescriptor': ip } for ip in ips]
213+
214+
def to_bool(value):
215+
return value.lower() == 'true'

waf_rate_limit/sample-event.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"StackId": "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid",
3+
"ResponseURL": "http://pre-signed-S3-url-for-response",
4+
"ResourceProperties": {
5+
"EnvironmentName": "prod",
6+
"Region": "ap-southeast-2",
7+
"Rate": "5000",
8+
"Priority": "10",
9+
"Action": "BLOCK",
10+
"Negated": "true",
11+
"WebACLId": "98f6fb51-3ad7-4cff-8f68-a2f96e707ac4",
12+
"Priority": "2",
13+
"IPSet": [
14+
{
15+
"Type": "IPV4",
16+
"Value": "123.22.64.68/32"
17+
}
18+
]
19+
},
20+
"RequestType": "Delete",
21+
"ResourceType": "Custom::WAFRateLimitFunction",
22+
"RequestId": "unique id for this create request",
23+
"LogicalResourceId": "WAFRateLimitFunction",
24+
"PhysicalResourceId": "d55e2c88-3eb7-40d2-8135-cf65d3624b35"
25+
}

0 commit comments

Comments
 (0)