changeset 472:7c3816d76c6c

Implement rate limiting on registration and login for #224.
author Brian Neal <bgneal@gmail.com>
date Thu, 25 Aug 2011 02:23:55 +0000
parents d83296cac940
children 5e826e232932
files gpp/accounts/forms.py gpp/accounts/views.py gpp/antispam/__init__.py gpp/antispam/decorators.py gpp/antispam/rate_limit.py gpp/antispam/tests.py gpp/antispam/tests/__init__.py gpp/antispam/tests/rate_limit_tests.py gpp/antispam/tests/utils_tests.py gpp/templates/antispam/blocked.html
diffstat 10 files changed, 277 insertions(+), 37 deletions(-) [+]
line wrap: on
line diff
--- a/gpp/accounts/forms.py	Wed Aug 17 02:02:20 2011 +0000
+++ b/gpp/accounts/forms.py	Thu Aug 25 02:23:55 2011 +0000
@@ -13,6 +13,7 @@
 from accounts.models import PendingUser
 from accounts.models import IllegalUsername
 from accounts.models import IllegalEmail
+from antispam.rate_limit import block_ip
 
 
 class RegisterForm(forms.Form):
@@ -109,6 +110,7 @@
         """
         answer = self.cleaned_data.get('question2')
         if answer:
+            block_ip(self.ip)
             self._validation_error('Wrong answer #2: %s' % answer)
         return answer
 
--- a/gpp/accounts/views.py	Wed Aug 17 02:02:20 2011 +0000
+++ b/gpp/accounts/views.py	Thu Aug 25 02:23:55 2011 +0000
@@ -13,10 +13,12 @@
 from accounts.models import PendingUser
 from accounts.forms import RegisterForm
 from accounts import create_new_user
+from antispam.decorators import rate_limit
 
 
 #######################################################################
 
+@rate_limit(count=10, interval=datetime.timedelta(minutes=1))
 def register(request):
     if request.user.is_authenticated():
         return HttpResponseRedirect(settings.LOGIN_REDIRECT_URL)
--- a/gpp/antispam/__init__.py	Wed Aug 17 02:02:20 2011 +0000
+++ b/gpp/antispam/__init__.py	Thu Aug 25 02:23:55 2011 +0000
@@ -1,4 +1,11 @@
+from django.contrib.auth import views as auth_views
+
+from antispam.decorators import rate_limit
+
 SPAM_PHRASE_KEY = "antispam.spam_phrases"
 BUSTED_MESSAGE = ("Your post has tripped our spam filter. Your account has "
         "been suspended pending a review of your post. If this was a mistake "
         "then we apologize; your account will be restored shortly.")
+
+# Install rate limiting on auth login
+auth_views.login = rate_limit()(auth_views.login)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/decorators.py	Thu Aug 25 02:23:55 2011 +0000
@@ -0,0 +1,34 @@
+"""
+This module contains decorators for the antispam application.
+
+"""
+from datetime import timedelta
+from functools import wraps
+
+from django.shortcuts import render
+
+from antispam.rate_limit import rate_check
+
+
+def rate_limit(count=10, interval=timedelta(minutes=1),
+        lockout=timedelta(hours=8)):
+
+    def decorator(fn):
+
+        @wraps(fn)
+        def wrapped(request, *args, **kwargs):
+
+            response = fn(request, *args, **kwargs)
+
+            if request.method == 'POST':
+                success = (response and response.has_header('location') and
+                        response.status_code == 302)
+                if not success:
+                    ip = request.META.get('REMOTE_ADDR')
+                    if rate_check(ip, count, interval, lockout):
+                        return render(request, 'antispam/blocked.html', status=403)
+
+            return response
+
+        return wrapped
+    return decorator
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/rate_limit.py	Thu Aug 25 02:23:55 2011 +0000
@@ -0,0 +1,105 @@
+"""
+This module contains the rate limiting functionality.
+
+"""
+import datetime
+import logging
+
+import redis
+from django.conf import settings
+
+
+logger = logging.getLogger(__name__)
+
+# Redis connection and database settings
+HOST = getattr(settings, 'RATE_LIMIT_REDIS_HOST', 'localhost')
+PORT = getattr(settings, 'RATE_LIMIT_REDIS_PORT', 6379)
+DB = getattr(settings, 'RATE_LIMIT_REDIS_DB', 0)
+
+
+def _make_key(ip):
+    """
+    Creates and returns a key string from a given IP address.
+
+    """
+    return 'rate-limit-' + ip
+
+
+def _get_connection():
+    """
+    Create and return a Redis connection. Returns None on failure.
+    """
+    try:
+        conn = redis.Redis(host=HOST, port=PORT, db=DB)
+        return conn
+    except redis.RedisError, e:
+        logger.error("rate limit: %s" % e)
+
+    return None
+
+
+def _to_seconds(interval):
+    """
+    Converts the timedelta interval object into a count of seconds.
+
+    """
+    return interval.days * 24 * 3600 + interval.seconds
+
+
+def rate_check(ip, set_point, interval, lockout):
+    """
+    This function performs a rate limit check.
+    One is added to a counter associated with the IP address. If the
+    counter exceeds set_point per interval, True is returned, and False
+    otherwise. If the set_point is exceeded for the first time, the counter
+    associated with the IP is set to expire according to the lockout parameter.
+    This locks the IP address as this function will then return True for the
+    period specified by lockout.
+
+    """
+    if not ip:
+        logger.error("rate_limit.rate_check could not get IP")
+        return False
+    key = _make_key(ip)
+
+    conn = _get_connection()
+    if not conn:
+        return False
+
+    val = conn.incr(key)
+
+    # Set expire time, if necessary.
+    # If this is the first time, set it according to interval.
+    # If the set_point has just been exceeded, set it according to lockout.
+    if val == 1:
+        conn.expire(key, _to_seconds(interval))
+    elif val == set_point:
+        conn.expire(key, _to_seconds(lockout))
+
+    tripped = val >= set_point
+
+    if tripped:
+        logger.info("Rate limiter tripped for %s; counter = %d", ip, val)
+
+    return tripped
+
+
+def block_ip(ip, count=1000000, interval=datetime.timedelta(weeks=2)):
+    """
+    This function jams the rate limit record for the given IP so that the IP is
+    blocked for the given interval. If the record doesn't exist, it is created.
+    This is useful for manually blocking an IP after detecting suspicious
+    behavior.
+
+    """
+    if not ip:
+        logger.error("rate_limit.block_ip could not get IP")
+        return
+
+    key = _make_key(ip)
+    conn = _get_connection()
+    if not conn:
+        return
+
+    conn.setex(key, count, _to_seconds(interval))
+    logger.info("Rate limiter blocked IP %s; %d / %s", ip, count, interval)
--- a/gpp/antispam/tests.py	Wed Aug 17 02:02:20 2011 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,37 +0,0 @@
-"""
-Tests for the antispam application.
-"""
-from django.test import TestCase
-from django.core.cache import cache
-
-from antispam import SPAM_PHRASE_KEY
-from antispam.models import SpamPhrase
-from antispam.utils import contains_spam
-
-
-class AntispamCase(TestCase):
-
-    def test_no_phrases(self):
-        """
-        Tests that an empty spam phrase table works.
-        """
-        cache.delete(SPAM_PHRASE_KEY)
-        self.assertFalse(contains_spam("Here is some random text."))
-
-    def test_phrases(self):
-        """
-        Simple test of some phrases.
-        """
-        SpamPhrase.objects.create(phrase="grytner")
-        SpamPhrase.objects.create(phrase="allday.ru")
-        SpamPhrase.objects.create(phrase="stefa.pl")
-
-        self.assert_(contains_spam("grytner"))
-        self.assert_(contains_spam("11grytner"))
-        self.assert_(contains_spam("11grytner>"))
-        self.assert_(contains_spam("1djkl jsd stefa.pl"))
-        self.assert_(contains_spam("1djkl jsd <stefa.pl---sd8"))
-        self.assert_(contains_spam("1dsdjallday.rukl jsd <stefa.pl---sd8"))
-        self.assert_(contains_spam(" 1djallday.rukl"))
-        self.assertFalse(contains_spam("this one is spam free."))
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/tests/__init__.py	Thu Aug 25 02:23:55 2011 +0000
@@ -0,0 +1,2 @@
+from rate_limit_tests import *
+from utils_tests import *
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/tests/rate_limit_tests.py	Thu Aug 25 02:23:55 2011 +0000
@@ -0,0 +1,77 @@
+"""
+Tests for the rate limiting function in the antispam application.
+
+"""
+import redis
+from django.test import TestCase
+from django.core.urlresolvers import reverse
+
+from antispam.rate_limit import _make_key
+
+
+class RateLimitTestCase(TestCase):
+    KEY = _make_key('127.0.0.1')
+
+    def setUp(self):
+        self.conn = redis.Redis(host='localhost', port=6379, db=0)
+        self.conn.delete(self.KEY)
+
+    def tearDown(self):
+        self.conn.delete(self.KEY)
+
+    def testRegistrationLockout(self):
+
+        for i in range(1, 11):
+            response = self.client.post(
+                    reverse('accounts-register'),
+                    {},
+                    follow=True)
+
+            if i < 10:
+                self.assertEqual(response.status_code, 200)
+                self.assertTemplateUsed(response, 'accounts/register.html')
+            elif i >= 10:
+                self.assertEqual(response.status_code, 403)
+                self.assertTemplateUsed(response, 'antispam/blocked.html')
+
+    def testLoginLockout(self):
+
+        for i in range(1, 11):
+            response = self.client.post(
+                    reverse('accounts-login'),
+                    {},
+                    follow=True)
+
+            if i < 10:
+                self.assertEqual(response.status_code, 200)
+                self.assertTemplateUsed(response, 'accounts/login.html')
+            elif i >= 10:
+                self.assertEqual(response.status_code, 403)
+                self.assertTemplateUsed(response, 'antispam/blocked.html')
+
+    def testHoneypotLockout(self):
+
+        response = self.client.post(
+                reverse('accounts-register'), {
+                    'username': u'test_user',
+                    'email': u'test_user@example.com',
+                    'password1': u'password',
+                    'password2': u'password',
+                    'agree_age': u'on',
+                    'agree_tos': u'on',
+                    'agree_privacy': u'on',
+                    'question1': u'101',
+                    'question2': u'DsjkdE$',
+                },
+                follow=True)
+
+        val = self.conn.get(self.KEY)
+        self.assertEqual(val, '1000001')
+
+        response = self.client.post(
+                reverse('accounts-login'),
+                {},
+                follow=True)
+
+        self.assertEqual(response.status_code, 403)
+        self.assertTemplateUsed(response, 'antispam/blocked.html')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/tests/utils_tests.py	Thu Aug 25 02:23:55 2011 +0000
@@ -0,0 +1,37 @@
+"""
+Tests for the antispam application.
+"""
+from django.test import TestCase
+from django.core.cache import cache
+
+from antispam import SPAM_PHRASE_KEY
+from antispam.models import SpamPhrase
+from antispam.utils import contains_spam
+
+
+class AntispamCase(TestCase):
+
+    def test_no_phrases(self):
+        """
+        Tests that an empty spam phrase table works.
+        """
+        cache.delete(SPAM_PHRASE_KEY)
+        self.assertFalse(contains_spam("Here is some random text."))
+
+    def test_phrases(self):
+        """
+        Simple test of some phrases.
+        """
+        SpamPhrase.objects.create(phrase="grytner")
+        SpamPhrase.objects.create(phrase="allday.ru")
+        SpamPhrase.objects.create(phrase="stefa.pl")
+
+        self.assert_(contains_spam("grytner"))
+        self.assert_(contains_spam("11grytner"))
+        self.assert_(contains_spam("11grytner>"))
+        self.assert_(contains_spam("1djkl jsd stefa.pl"))
+        self.assert_(contains_spam("1djkl jsd <stefa.pl---sd8"))
+        self.assert_(contains_spam("1dsdjallday.rukl jsd <stefa.pl---sd8"))
+        self.assert_(contains_spam(" 1djallday.rukl"))
+        self.assertFalse(contains_spam("this one is spam free."))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/antispam/blocked.html	Thu Aug 25 02:23:55 2011 +0000
@@ -0,0 +1,11 @@
+{% extends 'base.html' %}
+{% load url from future %}
+{% block title %}Blocked{% endblock %}
+{% block content %}
+<h2>Blocked</h2>
+<p class="error">
+Oops, we are detecting some strange behavior and are blocking this action. If you
+feel this is an error, please feel to <a href="{% url 'contact-form' %}">contact us</a>
+and let us know what you were doing on the site. Thank you.
+</p>
+{% endblock %}