From d8307f46728e839c63cbb24cac3d349fdf07c066 Mon Sep 17 00:00:00 2001
From: 0livd <0livd@users.noreply.github.com>
Date: Sun, 9 Jul 2017 21:32:11 +0200
Subject: [PATCH] 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
---
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 | 36 ++++++++++++++++++
ihatemoney/web.py | 22 ++++++++---
6 files changed, 90 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 271477aa..76ef3b0f 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
@@ -375,6 +376,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..2052895c 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,38 @@ 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):
+ 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)