summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFrédéric Péters <fpeters@entrouvert.com>2019-08-14 07:40:13 (GMT)
committerFrédéric Péters <fpeters@entrouvert.com>2019-08-14 09:50:52 (GMT)
commitc5387c8ae6e4a8f86abf959a85782dba37228001 (patch)
tree631abb0e77f69b55a10406f17678fc7ec3058432
parent972c03571e89c693a63bda8df7f97a421c08e1fd (diff)
downloadcombo-wip/35395-tracking-code-throttling.zip
combo-wip/35395-tracking-code-throttling.tar.gz
combo-wip/35395-tracking-code-throttling.tar.bz2
misc: add rate limiting to tracking code URLs (#35395)wip/35395-tracking-code-throttling
-rw-r--r--combo/apps/wcs/views.py69
-rw-r--r--combo/settings.py3
-rw-r--r--debian/control1
-rw-r--r--setup.py1
-rw-r--r--tests/conftest.py9
-rw-r--r--tests/test_wcs.py34
6 files changed, 98 insertions, 19 deletions
diff --git a/combo/apps/wcs/views.py b/combo/apps/wcs/views.py
index 09d4660..3c133c3 100644
--- a/combo/apps/wcs/views.py
+++ b/combo/apps/wcs/views.py
@@ -17,6 +17,8 @@
import re
from django.contrib import messages
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import JsonResponse, HttpResponseRedirect, HttpResponseBadRequest
from django.utils.http import urlquote
@@ -25,6 +27,8 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
+import ratelimit.utils
+
from .models import TrackingCodeInputCell
from .utils import get_wcs_services
@@ -41,13 +45,25 @@ class TrackingCodeView(View):
return super(TrackingCodeView, self).dispatch(*args, **kwargs)
@classmethod
- def search(self, code, wcs_site=None):
+ def search(self, code, request, wcs_site=None):
code = code.strip().upper()
if wcs_site:
wcs_sites = [get_wcs_services().get(wcs_site)]
else:
wcs_sites = get_wcs_services().values()
+ rate_limit_option = settings.WCS_TRACKING_CODE_RATE_LIMIT
+ if rate_limit_option and rate_limit_option != 'none':
+ for rate_limit in rate_limit_option.split():
+ ratelimited = ratelimit.utils.is_ratelimited(
+ request=request,
+ group='trackingcode',
+ key='ip',
+ rate=rate_limit,
+ increment=True)
+ if ratelimited:
+ raise PermissionDenied('rate limit reached (%s)' % rate_limit)
+
for wcs_site in wcs_sites:
response = requests.get('/api/code/' + urlquote(code),
remote_service=wcs_site, log_errors=False)
@@ -65,34 +81,53 @@ class TrackingCodeView(View):
return HttpResponseBadRequest('Missing code')
code = request.POST['code']
- url = self.search(code, wcs_site=cell.wcs_site)
- if url:
- return HttpResponseRedirect(url)
-
next_url = request.POST.get('url') or '/'
next_netloc = urlparse.urlparse(next_url).netloc
- if not (next_netloc and next_netloc != urlparse.urlparse(request.build_absolute_uri()).netloc):
- messages.error(self.request,
- _(u'The tracking code could not been found.'))
+ redirect_to_other_domain = bool(
+ next_netloc and next_netloc != urlparse.urlparse(request.build_absolute_uri()).netloc)
+
+ try:
+ url = self.search(code, request, wcs_site=cell.wcs_site)
+ except PermissionDenied:
+ if redirect_to_other_domain:
+ raise
+ else:
+ messages.error(self.request,
+ _(u'Looking up tracking code is currently rate limited.'))
else:
- if '?' in next_url:
- next_url += '&'
+ if url:
+ return HttpResponseRedirect(url)
+ if redirect_to_other_domain:
+ if '?' in next_url:
+ next_url += '&'
+ else:
+ next_url += '?'
+ next_url += 'unknown-tracking-code'
else:
- next_url += '?'
- next_url += 'unknown-tracking-code'
+ messages.error(self.request,
+ _(u'The tracking code could not been found.'))
return HttpResponseRedirect(next_url)
def tracking_code_search(request):
hits = []
+ response = {'data': hits, 'err': 0}
query = request.GET.get('q') or ''
query = query.strip().upper()
if re.match(r'^[BCDFGHJKLMNPQRSTVWXZ]{8}$', query):
- url = TrackingCodeView.search(query)
- if url:
+ try:
+ url = TrackingCodeView.search(query, request)
+ except PermissionDenied:
+ response['err'] = 1
hits.append({
- 'text': _('Use tracking code %s') % query,
- 'url': url,
+ 'text': _('Looking up tracking code is currently rate limited.'),
+ 'url': '#',
})
- return JsonResponse({'data': hits})
+ else:
+ if url:
+ hits.append({
+ 'text': _('Use tracking code %s') % query,
+ 'url': url,
+ })
+ return JsonResponse(response)
diff --git a/combo/settings.py b/combo/settings.py
index 7eb98b7..cca4fb3 100644
--- a/combo/settings.py
+++ b/combo/settings.py
@@ -310,6 +310,9 @@ REQUESTS_TIMEOUT = 28
# default duration of notifications (in days)
COMBO_DEFAULT_NOTIFICATION_DURATION = 3
+# tracking code thorttling
+WCS_TRACKING_CODE_RATE_LIMIT = '3/s 1500/d'
+
# predefined slots for assets
# example: {'banner': {'label': 'Banner image'}}
COMBO_ASSET_SLOTS = {}
diff --git a/debian/control b/debian/control
index 28d310d..fc0e1e1 100644
--- a/debian/control
+++ b/debian/control
@@ -22,6 +22,7 @@ Depends: ${misc:Depends}, ${python:Depends},
python-xstatic-roboto-fontface (<< 0.5.0.0),
python-eopayment (>= 1.35),
python-django-haystack (>= 2.4.0),
+ python-django-ratelimit,
python-sorl-thumbnail,
python-pil,
python-pywebpush,
diff --git a/setup.py b/setup.py
index b26c608..88ba3e7 100644
--- a/setup.py
+++ b/setup.py
@@ -161,6 +161,7 @@ setup(
'python-dateutil',
'djangorestframework>=3.3, <3.7',
'django-haystack',
+ 'django-ratelimit<3',
'whoosh',
'sorl-thumbnail',
'Pillow',
diff --git a/tests/conftest.py b/tests/conftest.py
index 2e4e6b5..65e3ad4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -50,3 +50,12 @@ def admin_user():
except User.DoesNotExist:
user = User.objects.create_superuser('admin', email=None, password='admin')
return user
+
+
+@pytest.fixture
+def nocache(settings):
+ settings.CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
+ }
+ }
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index 6d913e2..4f03bf9 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -611,7 +611,7 @@ def test_manager_current_forms(app, admin_user):
@wcs_present
-def test_tracking_code_cell(app):
+def test_tracking_code_cell(app, nocache):
Page.objects.all().delete()
page = Page(title='One', slug='index', template_name='standard')
page.save()
@@ -713,8 +713,9 @@ def test_cell_assets(app, admin_user):
assert u'>Picture — form title (test)<' in resp.text
@wcs_present
-def test_tracking_code_search(app):
+def test_tracking_code_search(app, nocache):
assert len(app.get('/api/search/tracking-code/').json.get('data')) == 0
+ assert app.get('/api/search/tracking-code/').json.get('err') == 0
assert len(app.get('/api/search/tracking-code/?q=123').json.get('data')) == 0
assert len(app.get('/api/search/tracking-code/?q=BBCCDFF').json.get('data')) == 0
assert len(app.get('/api/search/tracking-code/?q=BBCCDDFF').json.get('data')) == 0
@@ -723,6 +724,35 @@ def test_tracking_code_search(app):
assert len(app.get('/api/search/tracking-code/?q= cnphntfb').json.get('data')) == 1
@wcs_present
+def test_tracking_code_search_rate_limit(app):
+ for i in range(3):
+ assert app.get('/api/search/tracking-code/?q=BBCCDDFF').json.get('err') == 0
+ assert app.get('/api/search/tracking-code/?q=BBCCDDFF').json.get('err') == 1
+
+ Page.objects.all().delete()
+ page = Page(title='One', slug='index', template_name='standard')
+ page.save()
+ cell = TrackingCodeInputCell(page=page, placeholder='content', order=0)
+ cell.save()
+
+ resp = app.get('/')
+ for i in range(3): # make sure we hit ratelimit
+ app.get('/api/search/tracking-code/?q=BBCCDDFF')
+ resp.form['code'] = 'FOOBAR'
+ resp = resp.form.submit()
+ assert resp.status_code == 302
+ resp = resp.follow()
+ assert '<li class="error">Looking up tracking code is currently rate limited.</li>' in resp.text
+
+ resp = app.get('/')
+ for i in range(3): # make sure we hit ratelimit
+ app.get('/api/search/tracking-code/?q=BBCCDDFF')
+ resp.form['code'] = 'FOOBAR'
+ resp.form['url'] = 'http://example.net/'
+ resp = resp.form.submit(status=403)
+
+
+@wcs_present
def test_wcs_search_engines(app):
search_engines = engines.get_engines()
assert 'tracking-code' in search_engines.keys()