From c5387c8ae6e4a8f86abf959a85782dba37228001 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Wed, 14 Aug 2019 09:40:13 +0200
Subject: [PATCH] misc: add rate limiting to tracking code URLs (#35395)

---
 combo/apps/wcs/views.py | 69 +++++++++++++++++++++++++++++++----------
 combo/settings.py       |  3 ++
 debian/control          |  1 +
 setup.py                |  1 +
 tests/conftest.py       |  9 ++++++
 tests/test_wcs.py       | 34 ++++++++++++++++++--
 6 files changed, 98 insertions(+), 19 deletions(-)

diff --git a/combo/apps/wcs/views.py b/combo/apps/wcs/views.py
index 09d4660c..3c133c38 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 7eb98b7e..cca4fb36 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 28d310d5..fc0e1e17 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 b26c6087..88ba3e73 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 2e4e6b5c..65e3ad45 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 6d913e27..4f03bf9c 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
@@ -722,6 +723,35 @@ def test_tracking_code_search(app):
     assert len(app.get('/api/search/tracking-code/?q=BBCCDDFFG').json.get('data')) == 0
     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()
-- 
2.23.0.rc1

