From ec4a099f182629d86a7421af7d4899a655be684e Mon Sep 17 00:00:00 2001 From: 0livd Date: Sun, 20 Aug 2017 12:37:12 +0200 Subject: [PATCH] Protect admin endpoints against brute force attacks (#249) * Protect admin endpoints against brute force attacks Add a throttling mechanism to prevent a client brute forcing the authentication form, based on its ip address Closes #245 * Reset attempt counters if they get memory hungry --- ihatemoney/run.py | 6 +++ ihatemoney/tests/tests.py | 23 +++++++++++ .../translations/fr/LC_MESSAGES/messages.mo | Bin 8425 -> 8629 bytes .../translations/fr/LC_MESSAGES/messages.po | 12 ++++-- ihatemoney/utils.py | 39 ++++++++++++++++++ ihatemoney/web.py | 22 +++++++--- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/ihatemoney/run.py b/ihatemoney/run.py index 22cf235f..1d02405e 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -7,6 +7,7 @@ from flask_babel import Babel from flask_mail import Mail from flask_migrate import Migrate, upgrade, stamp from raven.contrib.flask import Sentry +from werkzeug.contrib.fixers import ProxyFix from ihatemoney.api import api from ihatemoney.models import db @@ -104,6 +105,11 @@ def create_app(configuration=None, instance_path='/etc/ihatemoney', load_configuration(app, configuration) app.wsgi_app = PrefixedWSGI(app) + # Get client's real IP + # Note(0livd): When running in a non-proxy setup, is vulnerable to requests + # with a forged X-FORWARDED-FOR header + app.wsgi_app = ProxyFix(app.wsgi_app) + validate_configuration(app) app.register_blueprint(web_interface) app.register_blueprint(api) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 5fc45a74..86f11f3e 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -9,6 +9,7 @@ import os import json from collections import defaultdict import six +from time import sleep from werkzeug.security import generate_password_hash from flask import session @@ -397,6 +398,28 @@ class BudgetTestCase(IhatemoneyTestCase): resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': ''}) self.assertNotIn('/create', resp.data.decode('utf-8')) + def test_login_throttler(self): + self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") + + # Authenticate 3 times with a wrong passsword + self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) + self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) + resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) + + self.assertIn('Too many failed login attempts, please retry later.', + resp.data.decode('utf-8')) + # Change throttling delay + import gc + for obj in gc.get_objects(): + if isinstance(obj, utils.LoginThrottler): + obj._delay = 0.005 + break + # Wait for delay to expire and retry logging in + sleep(1) + resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) + self.assertNotIn('Too many failed login attempts, please retry later.', + resp.data.decode('utf-8')) + def test_manage_bills(self): self.post_project("raclette") diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo index 210852b0cf2b263ee5f84967ecea82579ef5145b..2f46b710be1cb1d161cc8e4412eca856bbd84c02 100644 GIT binary patch delta 2210 zcmYM!e`wTo9LMp`e&m|oc57}+ZKvPKyg8XSyeT8;motUgWPyV8gYvw6Zyx*Z?z->y z%v;ovMG>N~)+8#h)MQ3vH0%d#Q4J(6{}2>M1QPj2vqJlAWm3t##+2YH3}OfJ$gD>V z@c_=nUaZE4eUmtk`fjW-reL0@a0?BiI3Hg}E#x@P!Bbd=XRsD$upF-&3=nmA5`;p7I+**r7_okx60f#$}j6?c@OJ%*L?; zCr~^68MVL?DvC%wHe(1K>_R<1j;zT{p*CEhB1U~sBt%;`fV*x(25>MB}>A002#|<{O5a6 zJA4VXqhZtlZ=-hhJ}T=cQ4^f<+s~nr^n%|$hnI)>Zfj5*C>$aI+9-U13iV|y#X$XR zas^QnFZEr4T0j&Dmf47R;5Prci(KX@ZaT7IY{%D75h|j_`wNK>@#mY;Knqb3Sb+*@ zJGS86s7TnzJLXB`GW-1YBd8F6h~%r8KqcXqe*FR}QkPMY{s$G263($w^EXpaGDJ`d z*^G+FW2laMkvuc+p?2~q>KE=3>WqIyg?t{B4y;F_ZMsnle-yb)iW^QSh#0KU! zM=5B>lc>;qh1%&Ezx^U=pev{aR`N}0r;AW$9!3qk29;!+{rZ6a{5kBT{bhU>i++0# zVJc{6k5JIW1E|l(MGId=4fqKvH>OdM`rUUv2VbNf#ryFZK8;;;j^k%YSJTWO5xf_} zm_km$jE0E6&gNYjdhjFE4$GNENmh@FOe-$OP8`DRsHFP_^#gVVl>-4%n#}+Iwf3Tryam0Gi$+^}OIs3-wo}KbyUUHZ1Mn;Y_zS6Tb zo6cB$EmOzhJMFkjDVFng(kGcn#_5+%S#B;Kw_P`xaNDe1b}s2S_K=mGI!0S8Z)cgE PDZE&=FEZX4`k?H8vpxF} delta 2009 zcmYM!Ylv4x7zf}f*GpUL+GcB+uIp_zbIS{9W?0_3K^KH5OOXoA%}Ov8Dq_h{gGiHA zQw&r}kkrCfqr?w}m{=qUfkGlk35tSUAjnF3et4ke?0IJ9%$a%TojL!#Q_oFqKh;pP zuK0V+e?R}fbV=?1zn$Gnxmk4=$M7&)c!^hWV2@JjIhdNtSZ0WctmVDz$%iXv@>=!z z>{Uv8S**}i!*brhcbPyovm5ubHxIIbCs@Ptbd_J-(*G|SurywCWx`=heH^dn1I#=# znfKuf~&kyxty7JH3x8g)&2z=)eo?a$2o{+*q1#lH}4JRFpgjXox!a5NxI7O zZnyFk)|P_I-s!zKsL9lOORYvn7kXm4Q|;{nqd%ZeSnoW(GXMWa4Kw^K#WbK)TMV zPv8^|av+v)Zcq6iuJDtF3{>MJwXh$Daw@ZuHfGOOFe_Zm1h$3A#2yaee!j%h)$>Pn z7DIWC349@&xRlB4x?b|1!X^#2v23l}!F$yA@^1dl1lVeyaweuTXJ9T{`6`p4txRTi zGUFa%e}2b|e~#(*2NTfcc7+_0t~N5(Qd6mCIt*l1IGkDWC}x0Z%*q~PKCfBK1PiM6 zCCuSmR<&>A8sF<@%mOCc_mOOWRv{@~#jCl4`jk(ZiNCHq!US-Pl2Oj^7XDd1Z@94( zSLy4Pts2EUIDyGf8#CSqOoleoIPK+2g{=GtlhUIc#*<7YYJ78wrIGpkM!KbaDwEbR1#Boul#yvy$1&FWY;} z3f3?KeZsNa!({F(6X4%WzdAR?(!vBXiKZyiS!ez-OChOwky+`YJm5RbKUS9M25+6mvF~GM`OH z<>&lX{TQe5LkHnap5}DU@sdYn8*k?Uw(uge@y8UJlg;aX1OaVEgV0rH=TMjIumeVSu9 zj|pfaWvT39PW557@&{(`FEj7;XueYK%>>fK1TvHfq?P$xCo=61GF$if1ceMRpE*2> znTc026K-Pm@?$2@PG+E!&7J$Y&+Oi@u)bTzxcZfyzt#7y>loaBZD&o>?k@iUoA|cQ diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po index 0f3339ef..65c295de 100644 --- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po @@ -162,14 +162,18 @@ msgstr "remboursements" msgid "Export file format" msgstr "Format du fichier d'export" -#: web.py:95 -msgid "This admin password is not the right one" -msgstr "Le mot de passe administrateur que vous avez entré n'est pas correct" - #: web.py:95 msgid "This private code is not the right one" msgstr "Le code que vous avez entré n'est pas correct" +#: web.py:106 +msgid "This admin password is not the right one. Only %(num)d attempts left." +msgstr "Le mot de passe administrateur que vous avez entré n'est pas correct. Plus que %(num)d tentatives." + +#: web.py:106 +msgid "Too many failed login attempts, please retry later." +msgstr "Trop d'échecs d'authentification successifs, veuillez réessayer plus tard." + #: web.py:147 #, python-format msgid "You have just created '%(project)s' to share your expenses" diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 4e79a37b..6af0112c 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -7,6 +7,7 @@ from json import dumps from flask import redirect from werkzeug.routing import HTTPException, RoutingException import six +from datetime import datetime, timedelta import csv @@ -131,3 +132,41 @@ def list_of_dicts2csv(dict_to_convert): # base64 encoding that works with both py2 and py3 and yield no warning base64_encode = base64.encodestring if six.PY2 else base64.encodebytes + + +class LoginThrottler(): + """Simple login throttler used to limit authentication attempts based on client's ip address. + When using multiple workers, remaining number of attempts can get inconsistent + but will still be limited to num_workers * max_attempts. + """ + def __init__(self, max_attempts=3, delay=1): + self._max_attempts = max_attempts + # Delay in minutes before resetting the attempts counter + self._delay = delay + self._attempts = {} + + def get_remaining_attempts(self, ip): + return self._max_attempts - self._attempts.get(ip, [datetime.now(), 0])[1] + + def increment_attempts_counter(self, ip): + # Reset all attempt counters when they get hungry for memory + if len(self._attempts) > 10000: + self.__init__() + if self._attempts.get(ip) is None: + # Store first attempt date and number of attempts since + self._attempts[ip] = [datetime.now(), 0] + self._attempts.get(ip)[1] += 1 + + def is_login_allowed(self, ip): + if self._attempts.get(ip) is None: + return True + # When the delay is expired, reset the counter + if datetime.now() - self._attempts.get(ip)[0] > timedelta(minutes=self._delay): + self.reset(ip) + return True + if self._attempts.get(ip)[1] >= self._max_attempts: + return False + return True + + def reset(self, ip): + self._attempts.pop(ip, None) diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 65c0ed61..cc2eeac6 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -27,10 +27,12 @@ from ihatemoney.forms import ( InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for, ExportForm ) -from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv +from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv, LoginThrottler main = Blueprint("main", __name__) +login_throttler = LoginThrottler(max_attempts=3, delay=1) + def requires_admin(f): """Require admin permissions for @requires_admin decorated endpoints. @@ -89,14 +91,24 @@ def admin(): form = AdminAuthenticationForm() goto = request.args.get('goto', url_for('.home')) if request.method == "POST": + client_ip = request.remote_addr + if not login_throttler.is_login_allowed(client_ip): + msg = _("Too many failed login attempts, please retry later.") + form.errors['admin_password'] = [msg] + return render_template("authenticate.html", form=form, admin_auth=True) if form.validate(): - if check_password_hash(current_app.config['ADMIN_PASSWORD'], form.admin_password.data): + # Valid password + if (check_password_hash(current_app.config['ADMIN_PASSWORD'], + form.admin_password.data)): session['is_admin'] = True session.update() + login_throttler.reset(client_ip) return redirect(goto) - else: - msg = _("This admin password is not the right one") - form.errors['admin_password'] = [msg] + # Invalid password + login_throttler.increment_attempts_counter(client_ip) + msg = _("This admin password is not the right one. Only %(num)d attempts left.", + num=login_throttler.get_remaining_attempts(client_ip)) + form.errors['admin_password'] = [msg] return render_template("authenticate.html", form=form, admin_auth=True)