Skip to content

Commit 6b94f32

Browse files
committed
Add PerRequestCacheThrottlingValidator
1 parent f6b173c commit 6b94f32

3 files changed

Lines changed: 67 additions & 0 deletions

File tree

example/apps/test_security/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
from .input_request_log import InputRequestLogTestCase
55
from .output_request_log import OutputRequestLogTestCase
66
from .partitioned_log import PartitionedLogTestCase
7+
from .throttling import PerRequestCacheThrottlingValidatorTestCase
78
from .utils import UtilsTestCase
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from django.core.cache import cache
2+
from django.test import RequestFactory, TestCase, override_settings
3+
4+
from germanium.tools import assert_raises
5+
6+
from security.throttling.exception import ThrottlingException
7+
from security.throttling.validators import PerRequestCacheThrottlingValidator
8+
9+
10+
class PerRequestCacheThrottlingValidatorTestCase(TestCase):
11+
12+
def setUp(self):
13+
cache.clear()
14+
self.factory = RequestFactory()
15+
16+
def _make_request(self, path='/test/', method='GET', ip='127.0.0.1'):
17+
request = self.factory.generic(method, path)
18+
request.META['REMOTE_ADDR'] = ip
19+
return request
20+
21+
@override_settings(SECURITY_THROTTLING_ENABLED=True)
22+
def test_validate_raises_throttling_exception_when_over_limit(self):
23+
validator = PerRequestCacheThrottlingValidator(60, 2)
24+
request = self._make_request()
25+
validator.validate(request)
26+
validator.validate(request)
27+
with assert_raises(ThrottlingException):
28+
validator.validate(request)
29+
30+
@override_settings(SECURITY_THROTTLING_ENABLED=True)
31+
def test_different_paths_are_counted_independently(self):
32+
validator = PerRequestCacheThrottlingValidator(60, 2)
33+
request_a = self._make_request(path='/path-a/')
34+
request_b = self._make_request(path='/path-b/')
35+
validator.validate(request_a)
36+
validator.validate(request_a)
37+
validator.validate(request_b)
38+
validator.validate(request_b)
39+
with assert_raises(ThrottlingException):
40+
validator.validate(request_a)

security/throttling/validators.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import hashlib
2+
import time
13
from datetime import timedelta
24

5+
from django.core.cache import cache
36
from django.utils import timezone
47
from django.utils.translation import gettext_lazy as _
58
from django.urls import resolve, Resolver404
@@ -55,6 +58,29 @@ def _validate(self, request):
5558
) < self.throttle_at
5659

5760

61+
class PerRequestCacheThrottlingValidator(PerRequestThrottlingValidator):
62+
"""
63+
Throttling validator that counts requests using Django's cache backend.
64+
Unlike PerRequestThrottlingValidator, this doesn't rely on async input request logs,
65+
making throttling effective immediately.
66+
"""
67+
68+
def _get_cache_key(self, request):
69+
ip = get_client_ip(request)[0] or ''
70+
window = int(time.time() / self.timeframe)
71+
raw = '{}:{}:{}:{}'.format(ip, request.method.upper(), request.path, window)
72+
return 'throttle:req:{}'.format(hashlib.md5(raw.encode()).hexdigest())
73+
74+
def _validate(self, request):
75+
key = self._get_cache_key(request)
76+
try:
77+
count = cache.incr(key)
78+
except ValueError:
79+
cache.set(key, 1, timeout=self.timeframe)
80+
count = 1
81+
return count <= self.throttle_at
82+
83+
5884
class LoginThrottlingValidator(ThrottlingValidator):
5985

6086
slug = None

0 commit comments

Comments
 (0)