# HG changeset patch # User Brian Neal # Date 1314239035 0 # Node ID 7c3816d76c6c1da32d15735e28290b5e6cad3f0b # Parent d83296cac9409cb25be3260c9d3da276d852bddc Implement rate limiting on registration and login for #224. diff -r d83296cac940 -r 7c3816d76c6c gpp/accounts/forms.py --- 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 diff -r d83296cac940 -r 7c3816d76c6c gpp/accounts/views.py --- 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) diff -r d83296cac940 -r 7c3816d76c6c gpp/antispam/__init__.py --- 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) diff -r d83296cac940 -r 7c3816d76c6c gpp/antispam/decorators.py --- /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 diff -r d83296cac940 -r 7c3816d76c6c gpp/antispam/rate_limit.py --- /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) diff -r d83296cac940 -r 7c3816d76c6c gpp/antispam/tests.py --- 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 = 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') diff -r d83296cac940 -r 7c3816d76c6c gpp/antispam/tests/utils_tests.py --- /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 Blocked +

+Oops, we are detecting some strange behavior and are blocking this action. If you +feel this is an error, please feel to contact us +and let us know what you were doing on the site. Thank you. +

+{% endblock %}