From 04a2cdab2f44a61b939c330ccb31832a5558ea33 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 1 Apr 2023 11:33:02 +0200 Subject: [PATCH 01/10] Only account for distinct attempts in rate limits --- core/admin/mailu/configuration.py | 2 +- core/admin/mailu/internal/nginx.py | 3 +++ core/admin/mailu/internal/views/auth.py | 2 +- core/admin/mailu/limiter.py | 6 +++++- core/admin/mailu/sso/views/base.py | 2 +- docs/configuration.rst | 7 ++++--- setup/templates/steps/config.html | 2 +- towncrier/newsfragments/2726.misc | 1 + 8 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 towncrier/newsfragments/2726.misc diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 3adf9a6c..2603bf38 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -44,7 +44,7 @@ DEFAULT_CONFIG = { 'AUTH_RATELIMIT_IP': '5/hour', 'AUTH_RATELIMIT_IP_V4_MASK': 24, 'AUTH_RATELIMIT_IP_V6_MASK': 48, - 'AUTH_RATELIMIT_USER': '100/day', + 'AUTH_RATELIMIT_USER': '50/day', 'AUTH_RATELIMIT_EXEMPTION': '', 'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400, 'DISABLE_STATISTICS': False, diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 577e5a44..d0d11052 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -85,6 +85,7 @@ def handle_authentication(headers): raw_user_email = urllib.parse.unquote(headers["Auth-User"]) raw_password = urllib.parse.unquote(headers["Auth-Pass"]) user_email = 'invalid' + password = 'invalid' try: user_email = raw_user_email.encode("iso8859-1").decode("utf8") password = raw_password.encode("iso8859-1").decode("utf8") @@ -107,6 +108,7 @@ def handle_authentication(headers): "Auth-Server": server, "Auth-User": user_email, "Auth-User-Exists": is_valid_user, + "Auth-Password": password, "Auth-Port": port } status, code = get_status(protocol, "authentication") @@ -115,6 +117,7 @@ def handle_authentication(headers): "Auth-Error-Code": code, "Auth-User": user_email, "Auth-User-Exists": is_valid_user, + "Auth-Password": password, "Auth-Wait": 0 } # Unexpected diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 27e8861c..01d1562f 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -48,7 +48,7 @@ def nginx_authentication(): if headers.get("Auth-Status") == "OK": utils.limiter.exempt_ip_from_ratelimits(client_ip) elif is_valid_user: - utils.limiter.rate_limit_user(username, client_ip) + utils.limiter.rate_limit_user(username, client_ip, password=response.headers.get('Auth-Password', None)) elif not is_from_webmail: utils.limiter.rate_limit_ip(client_ip, username) return response diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index d8b36111..f8e90101 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -68,9 +68,13 @@ class LimitWraperFactory(object): app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.') return is_rate_limited - def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): + def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=None): limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') if self.is_subject_to_rate_limits(ip): + truncated_password = hmac.new(bytearray(username, 'utf-8'), bytearray(password, 'utf-8'), 'sha256').hexdigest()[-6:] + if password and self.storage.get(f'dedup2-{username}-{truncated_password}') > 0: + return + self.storage.incr(f'dedup2-{username}-{truncated_password}', limits.parse(app.config['AUTH_RATELIMIT_USER']).GRANULARITY.seconds, True) limiter.hit(device_cookie if device_cookie_name == username else username) """ Device cookies as described on: diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 43237c75..42175f05 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -59,7 +59,7 @@ def login(): flask.flash(msg, "error") return response else: - utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip, username) + utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username, form.pw.data) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip, username) flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') flask.flash('Wrong e-mail or password', 'error') return flask.render_template('login.html', form=form, fields=fields) diff --git a/docs/configuration.rst b/docs/configuration.rst index e4bac5ff..56801ec8 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -47,10 +47,11 @@ accounts for a specific IP subnet as defined in ``AUTH_RATELIMIT_IP_V4_MASK`` (default: /24) and ``AUTH_RATELIMIT_IP_V6_MASK`` (default: /48). -The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting +The ``AUTH_RATELIMIT_USER`` (default: 50/day) holds a security setting for fighting attackers that attempt to guess a user's password (typically using a password -bruteforce attack). The value defines the limit of authentication attempts allowed -for any given account within a specific timeframe. +bruteforce attack). The value defines the limit of distinct authentication attempts +allowed for any given account within a specific timeframe. Multiple attempts for the +same account with the same password only counts for one. The ``AUTH_RATELIMIT_EXEMPTION_LENGTH`` (default: 86400) is the number of seconds after a successful login for which a specific IP address is exempted from rate limits. diff --git a/setup/templates/steps/config.html b/setup/templates/steps/config.html index b3b5fe87..025a2a2c 100644 --- a/setup/templates/steps/config.html +++ b/setup/templates/steps/config.html @@ -47,7 +47,7 @@ Or in plain english: if receivers start to classify your mail as spam, this post

/ day + value="50" required > / day

diff --git a/towncrier/newsfragments/2726.misc b/towncrier/newsfragments/2726.misc new file mode 100644 index 00000000..f7eb41e3 --- /dev/null +++ b/towncrier/newsfragments/2726.misc @@ -0,0 +1 @@ +Change the behaviour of AUTH_RATELIMIT_USER and only account for distinct attempts. Same username and same password is now a only accounted once per period. From 795a7bafa23cd3d5a8f3e976a47b59cd868d8e07 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 1 Apr 2023 12:22:44 +0200 Subject: [PATCH 02/10] should never happen but heh --- core/admin/mailu/limiter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index f8e90101..47658ba4 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -68,7 +68,7 @@ class LimitWraperFactory(object): app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.') return is_rate_limited - def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=None): + def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''): limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') if self.is_subject_to_rate_limits(ip): truncated_password = hmac.new(bytearray(username, 'utf-8'), bytearray(password, 'utf-8'), 'sha256').hexdigest()[-6:] From 616e4a773436ac70d587ccb8b3ee089abb05b0a2 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sun, 2 Apr 2023 16:35:15 +0200 Subject: [PATCH 03/10] Ensure we always ask for the existing password before allowing a change --- core/admin/mailu/ui/forms.py | 6 ++++++ core/admin/mailu/ui/templates/sidebar.html | 2 +- core/admin/mailu/ui/views/users.py | 21 ++++++++++++++------- towncrier/newsfragments/2733.misc | 1 + 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 towncrier/newsfragments/2733.misc diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 79c76450..c9bbb251 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -128,6 +128,12 @@ class UserPasswordForm(flask_wtf.FlaskForm): pwned = fields.HiddenField(label='', default=-1) submit = fields.SubmitField(_('Update password')) +class UserPasswordChangeForm(flask_wtf.FlaskForm): + current_pw = fields.PasswordField(_('Current password'), [validators.DataRequired()]) + pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) + pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()]) + pwned = fields.HiddenField(label='', default=-1) + submit = fields.SubmitField(_('Update password')) class UserReplyForm(flask_wtf.FlaskForm): reply_enabled = fields.BooleanField(_('Enable automatic reply')) diff --git a/core/admin/mailu/ui/templates/sidebar.html b/core/admin/mailu/ui/templates/sidebar.html index 526b5908..3fd22f0e 100644 --- a/core/admin/mailu/ui/templates/sidebar.html +++ b/core/admin/mailu/ui/templates/sidebar.html @@ -20,7 +20,7 @@