From e3dd6d94ff6f17475fa40f09ff00d589996a311d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 13 Aug 2019 13:58:55 +0200
Subject: [PATCH] misc: add rate limiting to tracking code URL (#35386)

---
 debian/control           |  1 +
 jenkins.sh               |  2 +-
 setup.py                 |  1 +
 tests/conftest.py        |  9 +++++++++
 tests/test_form_pages.py | 27 +++++++++++++++++----------
 wcs/forms/root.py        | 12 ++++++++++++
 6 files changed, 41 insertions(+), 11 deletions(-)

diff --git a/debian/control b/debian/control
index 5fe865fb4..d3842b095 100644
--- a/debian/control
+++ b/debian/control
@@ -15,6 +15,7 @@ Depends: ${misc:Depends}, ${python:Depends},
     python-hobo,
     graphviz,
     python-django-ckeditor,
+    python-django-ratelimit,
     python-feedparser,
     python-imaging,
     python-pyproj,
diff --git a/jenkins.sh b/jenkins.sh
index bb5707bd9..f4d9cb6aa 100755
--- a/jenkins.sh
+++ b/jenkins.sh
@@ -27,7 +27,7 @@ $PIP_BIN install --upgrade setuptools
 $PIP_BIN install --upgrade 'pytest<4.1' WebTest mock pytest-cov pyquery pytest-django
 $PIP_BIN install --upgrade 'pylint<1.8' # 1.8 broken (cf build #3023)
 $PIP_BIN install git+https://git.entrouvert.org/debian/django-ckeditor.git
-$PIP_BIN install --upgrade 'Django<1.12' 'gadjo' 'pyproj'
+$PIP_BIN install --upgrade 'Django<1.12' 'gadjo' 'pyproj' 'django-ratelimit<3'
 
 DJANGO_SETTINGS_MODULE=wcs.settings \
 WCS_SETTINGS_FILE=tests/settings.py \
diff --git a/setup.py b/setup.py
index 24122c12e..3c9e50132 100644
--- a/setup.py
+++ b/setup.py
@@ -109,6 +109,7 @@ setup(
         install_requires=[
             'gadjo>=0.53',
             'django-ckeditor<=4.5.3',
+            'django-ratelimit<3',
             'XStatic-Leaflet',
             'pyproj',
         ],
diff --git a/tests/conftest.py b/tests/conftest.py
index 783847ac0..a28cdfdc8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -68,3 +68,12 @@ def sms_mocking():
 def http_requests():
     with HttpRequestsMocking() as http_requests:
         yield http_requests
+
+
+@pytest.fixture
+def nocache(settings):
+    settings.CACHES = {
+        'default': {
+            'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
+        }
+    }
diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 5e90079ca..a60903ce2 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -55,7 +55,6 @@ def assert_equal_zip(stream1, stream2):
         t1, t2 = z1.read(name), z2.read(name)
         assert t1 == t2, 'file "%s" differs' % name
 
-
 def pytest_generate_tests(metafunc):
     if 'pub' in metafunc.fixturenames:
         metafunc.parametrize('pub', ['pickle', 'sql', 'pickle-templates', 'pickle-lazy'], indirect=True)
@@ -1311,7 +1310,7 @@ def get_displayed_tracking_code(resp):
             break
     return tracking_code
 
-def test_form_tracking_code(pub):
+def test_form_tracking_code(pub, nocache):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
     formdef.enable_tracking_codes = True
@@ -1412,8 +1411,16 @@ def test_form_tracking_code(pub):
     assert resp.location == 'http://example.net/test/%s' % formdata_id
     resp = resp.follow()
 
+def test_form_tracking_code_rate_limit(pub):
+    # three errors
+    get_app(pub).get('/code/ABC/load', status=404)
+    get_app(pub).get('/code/ABC/load', status=404)
+    get_app(pub).get('/code/ABC/load', status=404)
+    # and out
+    get_app(pub).get('/code/ABC/load', status=403)
+    get_app(pub).get('/code/ABC/load', status=403)
 
-def test_form_tracking_code_as_user(pub):
+def test_form_tracking_code_as_user(pub, nocache):
     user = create_user(pub)
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
@@ -1493,7 +1500,7 @@ def test_form_tracking_code_as_user(pub):
     resp = app.get('/code/%s/load' % tracking_code,
             headers={'User-agent': 'Googlebot'}, status=403)
 
-def test_form_empty_tracking_code(pub):
+def test_form_empty_tracking_code(pub, nocache):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
     formdef.enable_tracking_codes = True
@@ -1512,7 +1519,7 @@ def test_form_empty_tracking_code(pub):
     assert resp.location == 'http://example.net/code/%s/load' % tracking_code
     resp = resp.follow(status=404)
 
-def test_form_tracking_code_email(pub, emails):
+def test_form_tracking_code_email(pub, emails, nocache):
     formdef = create_formdef()
     formdef.data_class().wipe()
     formdef.fields = [fields.StringField(id='0', label='string'),
@@ -1541,7 +1548,7 @@ def test_form_tracking_code_email(pub, emails):
     resp = resp.follow()
     assert resp.forms[1]['f0'].value == 'barfoo'
 
-def test_form_tracking_code_remove_draft(pub):
+def test_form_tracking_code_remove_draft(pub, nocache):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
     formdef.enable_tracking_codes = True
@@ -1587,7 +1594,7 @@ def test_form_tracking_code_remove_draft(pub):
     assert resp.location == 'http://example.net/'
     assert formdef.data_class().count() == 0
 
-def test_form_tracking_code_remove_empty_draft(pub):
+def test_form_tracking_code_remove_empty_draft(pub, nocache):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
     formdef.enable_tracking_codes = True
@@ -1635,7 +1642,7 @@ def test_form_tracking_code_remove_empty_draft(pub):
     assert resp.location == 'http://example.net/'
     assert formdef.data_class().count() == 0
 
-def test_form_discard_draft(pub):
+def test_form_discard_draft(pub, nocache):
     user = create_user(pub)
 
     formdef = create_formdef()
@@ -1733,7 +1740,7 @@ def test_form_discard_draft(pub):
     resp = resp.forms[1].submit('cancel')
     assert [x.status for x in formdef.data_class().select()] == ['draft']
 
-def test_form_invalid_tracking_code(pub):
+def test_form_invalid_tracking_code(pub, nocache):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
     formdef.enable_tracking_codes = True
@@ -1785,7 +1792,7 @@ def test_form_invalid_tracking_code(pub):
     assert resp.location == 'http://example.net/code/%s/load' % code.id
     resp = resp.follow(status=404)
 
-def test_form_tracking_code_as_variable(pub):
+def test_form_tracking_code_as_variable(pub, nocache):
     formdef = create_formdef()
     formdef.fields = [fields.PageField(id='0', label='1st page', type='page'),
             fields.StringField(id='1', label='string'),
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index 951d0fe15..18d8bf5f0 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -29,6 +29,8 @@ from django.utils.http import quote
 from django.utils.six import StringIO
 from django.utils.safestring import mark_safe
 
+import ratelimit.utils
+
 from quixote import (get_publisher, get_request, get_response, get_session,
         get_session_manager, redirect)
 from quixote.directory import Directory, AccessControlled
@@ -148,6 +150,16 @@ class TrackingCodeDirectory(Directory):
         return r.getvalue()
 
     def load(self):
+        rate_limit = get_publisher().get_site_option('rate-limit') or '3/s'
+        if rate_limit != 'none':
+            ratelimited = ratelimit.utils.is_ratelimited(
+                    request=get_request().django_request,
+                    group='trackingcode',
+                    key='ip',
+                    rate=rate_limit,
+                    increment=True)
+            if ratelimited:
+                raise errors.AccessForbiddenError()
         try:
             tracking_code = get_publisher().tracking_code_class.get(self.code)
             if tracking_code.formdata_id is None:
-- 
2.23.0.rc1

