From 89ea51d5700e3b8a7cf88fd06b45144c8e1631eb Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 23 Sep 2021 18:40:49 +0200 Subject: [PATCH 01/27] Implement rate-limits --- core/admin/mailu/__init__.py | 1 + core/admin/mailu/configuration.py | 7 +++- core/admin/mailu/internal/nginx.py | 13 +++++- core/admin/mailu/internal/views/auth.py | 49 ++++++++++++++++------ core/admin/mailu/limiter.py | 56 ++++++++++++++++++++++++- core/admin/mailu/ui/views/base.py | 18 ++++++-- core/admin/mailu/utils.py | 11 +++++ core/nginx/conf/nginx.conf | 1 + docs/configuration.rst | 21 ++++++---- docs/swarm/master/README.md | 3 ++ setup/flavors/compose/mailu.env | 2 +- towncrier/newsfragments/116.feature | 1 + towncrier/newsfragments/1194.bugfix | 1 + towncrier/newsfragments/1612.feature | 1 + 14 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 towncrier/newsfragments/116.feature create mode 100644 towncrier/newsfragments/1194.bugfix create mode 100644 towncrier/newsfragments/1612.feature diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 9b712512..43e58171 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -28,6 +28,7 @@ def create_app_from_config(config): utils.proxy.init_app(app) utils.migrate.init_app(app, models.db) + app.device_cookie_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('DEVICE_COOKIE_KEY', 'utf-8'), 'sha256').digest() app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest() # Initialize list of translations diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 4401888a..b0a1bf7b 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -36,8 +36,11 @@ DEFAULT_CONFIG = { 'TLS_FLAVOR': 'cert', 'INBOUND_TLS_ENFORCE': False, 'DEFER_ON_TLS_ERROR': True, - 'AUTH_RATELIMIT': '1000/minute;10000/hour', - 'AUTH_RATELIMIT_SUBNET': False, + 'AUTH_RATELIMIT_IP': '10/hour', + 'AUTH_RATELIMIT_IP_V4_MASK': 24, + 'AUTH_RATELIMIT_IP_V6_MASK': 56, + 'AUTH_RATELIMIT_USER': '100/day', + 'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400, 'DISABLE_STATISTICS': False, # Mail settings 'DMARC_RUA': None, diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 167341e2..78f83e99 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -19,6 +19,11 @@ STATUSES = { "encryption": ("Must issue a STARTTLS command first", { "smtp": "530 5.7.0" }), + "ratelimit": ("Temporary authentication failure (rate-limit)", { + "imap": "LIMIT", + "smtp": "451 4.3.2", + "pop3": "-ERR [LOGIN-DELAY] Retry later" + }), } def check_credentials(user, password, ip, protocol=None): @@ -71,8 +76,9 @@ def handle_authentication(headers): } # Authenticated user elif method == "plain": + is_valid_user = False service_port = int(urllib.parse.unquote(headers["Auth-Port"])) - if service_port == 25: + if 'Auth-Port' in headers and service_port == 25: return { "Auth-Status": "AUTH not supported", "Auth-Error-Code": "502 5.5.1", @@ -91,18 +97,23 @@ def handle_authentication(headers): app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}') else: user = models.User.query.get(user_email) + is_valid_user = True ip = urllib.parse.unquote(headers["Client-Ip"]) if check_credentials(user, password, ip, protocol): server, port = get_server(headers["Auth-Protocol"], True) return { "Auth-Status": "OK", "Auth-Server": server, + "Auth-User": user_email, + "Auth-User-Exists": is_valid_user, "Auth-Port": port } status, code = get_status(protocol, "authentication") return { "Auth-Status": status, "Auth-Error-Code": code, + "Auth-User": user_email, + "Auth-User-Exists": is_valid_user, "Auth-Wait": 0 } # Unexpected diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 1686e1cb..457efd4b 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -5,19 +5,17 @@ from flask import current_app as app import flask import flask_login import base64 -import ipaddress - @internal.route("/auth/email") def nginx_authentication(): """ Main authentication endpoint for Nginx email server """ - limiter = utils.limiter.get_limiter(app.config["AUTH_RATELIMIT"], "auth-ip") client_ip = flask.request.headers["Client-Ip"] - if not limiter.test(client_ip): + if utils.limiter.should_rate_limit_ip(client_ip): + status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') response = flask.Response() - response.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded' - response.headers['Auth-Error-Code'] = '451 4.3.2' + response.headers['Auth-Status'] = status + response.headers['Auth-Error-Code'] = code if int(flask.request.headers['Auth-Login-Attempt']) < 10: response.headers['Auth-Wait'] = '3' return response @@ -25,14 +23,25 @@ def nginx_authentication(): response = flask.Response() for key, value in headers.items(): response.headers[key] = str(value) + is_valid_user = False + if "Auth-User-Exists" in response.headers and response.headers["Auth-User-Exists"]: + username = response.headers["Auth-User"] + if utils.limiter.should_rate_limit_user(username, client_ip): + # FIXME could be done before handle_authentication() + status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') + response = flask.Response() + response.headers['Auth-Status'] = status + response.headers['Auth-Error-Code'] = code + if int(flask.request.headers['Auth-Login-Attempt']) < 10: + response.headers['Auth-Wait'] = '3' + return response + is_valid_user = True if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"): - limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"]) != 'False' - subnet = ipaddress.ip_network(app.config["SUBNET"]) - if limit_subnet or ipaddress.ip_address(client_ip) not in subnet: - limiter.hit(flask.request.headers["Client-Ip"]) + utils.limiter.rate_limit_user(username, client_ip) if is_valid_user else rate_limit_ip(client_ip) + elif ("Auth-Status" in headers) and (headers["Auth-Status"] == "OK"): + utils.limiter.exempt_ip_from_ratelimits(client_ip) return response - @internal.route("/auth/admin") def admin_authentication(): """ Fails if the user is not an authenticated admin. @@ -60,15 +69,29 @@ def user_authentication(): def basic_authentication(): """ Tries to authenticate using the Authorization header. """ + client_ip = flask.request.headers["X-Real-IP"] if 'X-Real-IP' in flask.request.headers else flask.request.remote_addr + if utils.limiter.should_rate_limit_ip(client_ip): + response = flask.Response(status=401) + response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit from one source exceeded"' + response.headers['Retry-After'] = '60' + return response authorization = flask.request.headers.get("Authorization") if authorization and authorization.startswith("Basic "): encoded = authorization.replace("Basic ", "") user_email, password = base64.b64decode(encoded).split(b":", 1) - user = models.User.query.get(user_email.decode("utf8")) - if nginx.check_credentials(user, password.decode('utf-8'), flask.request.remote_addr, "web"): + user_email = user_email.decode("utf8") + if utils.limiter.should_rate_limit_user(user_email, client_ip): + response = flask.Response(status=401) + response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit for this username exceeded"' + response.headers['Retry-After'] = '60' + return response + user = models.User.query.get(user_email) + if user and nginx.check_credentials(user, password.decode('utf-8'), flask.request.remote_addr, "web"): response = flask.Response() response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "") + utils.limiter.exempt_ip_from_ratelimits(client_ip) return response + utils.limiter.rate_limit_user(user_email, client_ip) if user else utils.limiter.rate_limit_ip(client_ip) response = flask.Response(status=401) response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"' return response diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index b5f99915..70b09f21 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -1,7 +1,12 @@ +from mailu import utils +from flask import current_app as app +import base64 import limits import limits.storage import limits.strategies +import hmac +import secrets class LimitWrapper(object): """ Wraps a limit by providing the storage, item and identifiers @@ -31,4 +36,53 @@ class LimitWraperFactory(object): self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage) def get_limiter(self, limit, *args): - return LimitWrapper(self.limiter, limits.parse(limit), *args) \ No newline at end of file + return LimitWrapper(self.limiter, limits.parse(limit), *args) + + def is_subject_to_rate_limits(self, ip): + return not (self.storage.get(f'exempt-{ip}') > 0) + + def exempt_ip_from_ratelimits(self, ip): + self.storage.incr(f'exempt-{ip}', app.config["AUTH_RATELIMIT_EXEMPTION_LENGTH"], True) + + def should_rate_limit_ip(self, ip): + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip') + client_network = utils.extract_network_from_ip(ip) + return self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) + + def rate_limit_ip(self, ip): + if ip != app.config['WEBMAIL_ADDRESS']: + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip') + client_network = utils.extract_network_from_ip(ip) + if self.is_subject_to_rate_limits(ip): + limiter.hit(client_network) + + def should_rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') + return self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username) + + def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') + if self.is_subject_to_rate_limits(ip): + limiter.hit(device_cookie if device_cookie_name == username else username) + + """ Device cookies as described on: + https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies + """ + def parse_device_cookie(self, cookie): + try: + login, nonce, _ = cookie.split('$') + if hmac.compare_digest(cookie, self.device_cookie(login, nonce)): + return nonce, login + except: + pass + return None, None + + """ Device cookies don't require strong crypto: + 72bits of nonce, 96bits of signature is more than enough + and these values avoid padding in most cases + """ + def device_cookie(self, username, nonce=None): + if not nonce: + nonce = secrets.token_urlsafe(9) + sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8') + return f'{username}${nonce}${sig}' diff --git a/core/admin/mailu/ui/views/base.py b/core/admin/mailu/ui/views/base.py index ecd3089a..30173acf 100644 --- a/core/admin/mailu/ui/views/base.py +++ b/core/admin/mailu/ui/views/base.py @@ -1,4 +1,4 @@ -from mailu import models +from mailu import models, utils from mailu.ui import ui, forms, access from flask import current_app as app @@ -14,16 +14,28 @@ def index(): @ui.route('/login', methods=['GET', 'POST']) def login(): + client_ip = flask.request.headers["X-Real-IP"] if 'X-Real-IP' in flask.request.headers else flask.request.remote_addr form = forms.LoginForm() if form.validate_on_submit(): - user = models.User.login(form.email.data, form.pw.data) + device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit')) + username = form.email.data + if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip): + flask.flash('Too many attempts from your IP (rate-limit)', 'error') + return flask.render_template('login.html', form=form) + if utils.limiter.should_rate_limit_user(username, client_ip, device_cookie, device_cookie_username): + flask.flash('Too many attempts for this user (rate-limit)', 'error') + return flask.render_template('login.html', form=form) + user = models.User.login(username, form.pw.data) if user: flask.session.regenerate() flask_login.login_user(user) endpoint = flask.request.args.get('next', '.index') - return flask.redirect(flask.url_for(endpoint) + response = flask.redirect(flask.url_for(endpoint) or flask.url_for('.index')) + response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('ui.login')) + 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) flask.flash('Wrong e-mail or password', 'error') return flask.render_template('login.html', form=form) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 34f52d8c..6fce17bc 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -17,10 +17,12 @@ from multiprocessing import Value from mailu import limiter +from flask import current_app as app import flask import flask_login import flask_migrate import flask_babel +import ipaddress import redis from flask.sessions import SessionMixin, SessionInterface @@ -70,6 +72,15 @@ def has_dane_record(domain, timeout=10): # Rate limiter limiter = limiter.LimitWraperFactory() +def extract_network_from_ip(ip): + n = ipaddress.ip_network(ip) + if isinstance(n, ipaddress.IPv4Network): + return str(n.supernet(prefixlen_diff=(32-int(app.config["AUTH_RATELIMIT_IP_V4_MASK"]))).network_address) + elif isinstance(n, ipaddress.IPv6Network): + return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address) + else: # not sure what to do with it + return ip + # Application translation babel = flask_babel.Babel() diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf index bfd664de..f013176d 100644 --- a/core/nginx/conf/nginx.conf +++ b/core/nginx/conf/nginx.conf @@ -217,6 +217,7 @@ http { location /internal { internal; + proxy_set_header X-Real-IP $remote_addr; proxy_set_header Authorization $http_authorization; proxy_pass_header Authorization; proxy_pass http://$admin; diff --git a/docs/configuration.rst b/docs/configuration.rst index 5f17b57e..73b56204 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -39,14 +39,21 @@ address. The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses that are allowed to send emails from any existing address (spoofing the sender). -The ``AUTH_RATELIMIT`` holds a security setting for fighting attackers that -try to guess user passwords. The value is the limit of failed authentication attempts -that a single IP address can perform against IMAP, POP and SMTP authentication endpoints. +The ``AUTH_RATELIMIT_IP`` (default: 10/hour) holds a security setting for fighting +attackers that waste server ressources by trying to guess user passwords (typically +using a password spraying attack). The value defines the limit of authentication +attempts that will be processed on non-existing accounts for a specific IP subnet +(as defined in ``AUTH_RATELIMIT_IP_V4_MASK`` and ``AUTH_RATELIMIT_IP_V6_MASK`` below). -If ``AUTH_RATELIMIT_SUBNET`` is ``True`` (default: False), the ``AUTH_RATELIMIT`` -rules does also apply to auth requests coming from ``SUBNET``, especially for the webmail. -If you disable this, ensure that the rate limit on the webmail is enforced in a different -way (e.g. roundcube plug-in), otherwise an attacker can simply bypass the limit using webmail. +The ``AUTH_RATELIMIT_USER`` (default: 100/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. + +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. +This ensures that users behind a NAT don't get locked out when a single client is +misconfigured... but also potentially allow for users to attack each-other. The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to ``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`. diff --git a/docs/swarm/master/README.md b/docs/swarm/master/README.md index 42e742da..0e933308 100644 --- a/docs/swarm/master/README.md +++ b/docs/swarm/master/README.md @@ -100,6 +100,9 @@ https://github.com/moby/moby/issues/25526#issuecomment-336363408 ### Don't create an open relay ! As a side effect of this ingress mode "feature", make sure that the ingress subnet is not in your RELAYHOST, otherwise you would create an smtp open relay :-( +### Ratelimits + +When using ingress mode you probably want to disable rate limits, because all requests originate from the same ip address. Otherwise automatic login attempts can easily DoS the legitimate users. ## Scalability - smtp and imap are scalable diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env index 52f4ee04..0ba39019 100644 --- a/setup/flavors/compose/mailu.env +++ b/setup/flavors/compose/mailu.env @@ -29,7 +29,7 @@ POSTMASTER={{ postmaster }} # Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) TLS_FLAVOR={{ tls_flavor }} -# Authentication rate limit (per source IP address) +# Authentication rate limit (per /24 on ipv4 and /56 on ipv6) {% if auth_ratelimit_pm > '0' %} AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute {% endif %} diff --git a/towncrier/newsfragments/116.feature b/towncrier/newsfragments/116.feature new file mode 100644 index 00000000..4f73e7a8 --- /dev/null +++ b/towncrier/newsfragments/116.feature @@ -0,0 +1 @@ + Make the rate limit apply to a subnet rather than a specific IP (/24 for v4 and /56 for v6) diff --git a/towncrier/newsfragments/1194.bugfix b/towncrier/newsfragments/1194.bugfix new file mode 100644 index 00000000..866c6c3b --- /dev/null +++ b/towncrier/newsfragments/1194.bugfix @@ -0,0 +1 @@ +Fix rate-limiting on /webdav/ diff --git a/towncrier/newsfragments/1612.feature b/towncrier/newsfragments/1612.feature new file mode 100644 index 00000000..04d8d508 --- /dev/null +++ b/towncrier/newsfragments/1612.feature @@ -0,0 +1 @@ +Refactor the rate limiter to ensure that it performs as intented. From a9340e61f538eccc8f8aaa7ae27eb9e303aaca42 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 23 Sep 2021 18:48:23 +0200 Subject: [PATCH 02/27] Log auth attempts on /admin --- core/admin/mailu/ui/views/base.py | 2 ++ towncrier/newsfragments/1926.feature | 1 + 2 files changed, 3 insertions(+) create mode 100644 towncrier/newsfragments/1926.feature diff --git a/core/admin/mailu/ui/views/base.py b/core/admin/mailu/ui/views/base.py index 30173acf..05211804 100644 --- a/core/admin/mailu/ui/views/base.py +++ b/core/admin/mailu/ui/views/base.py @@ -33,9 +33,11 @@ def login(): response = flask.redirect(flask.url_for(endpoint) or flask.url_for('.index')) response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('ui.login')) + flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') 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) + 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) diff --git a/towncrier/newsfragments/1926.feature b/towncrier/newsfragments/1926.feature new file mode 100644 index 00000000..fdd4ae87 --- /dev/null +++ b/towncrier/newsfragments/1926.feature @@ -0,0 +1 @@ +Log authentication attempts on the admin portal From cab0ce2017c10828944a71100210e70a959a54fe Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 23 Sep 2021 19:01:09 +0200 Subject: [PATCH 03/27] doh --- core/admin/mailu/internal/nginx.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 78f83e99..ff7070ab 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -77,8 +77,7 @@ def handle_authentication(headers): # Authenticated user elif method == "plain": is_valid_user = False - service_port = int(urllib.parse.unquote(headers["Auth-Port"])) - if 'Auth-Port' in headers and service_port == 25: + if 'Auth-Port' in headers and int(urllib.parse.unquote(headers["Auth-Port"])) == 25: return { "Auth-Status": "AUTH not supported", "Auth-Error-Code": "502 5.5.1", From 64bc7972cc41ad0cf42eb23a7fb8fefb3f97cd2d Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Fri, 24 Sep 2021 09:57:28 +0200 Subject: [PATCH 04/27] Make AUTH_RATELIMIT_IP 60/hour as discussed --- core/admin/mailu/configuration.py | 2 +- docs/configuration.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index b0a1bf7b..fa6723f1 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -36,7 +36,7 @@ DEFAULT_CONFIG = { 'TLS_FLAVOR': 'cert', 'INBOUND_TLS_ENFORCE': False, 'DEFER_ON_TLS_ERROR': True, - 'AUTH_RATELIMIT_IP': '10/hour', + 'AUTH_RATELIMIT_IP': '60/hour', 'AUTH_RATELIMIT_IP_V4_MASK': 24, 'AUTH_RATELIMIT_IP_V6_MASK': 56, 'AUTH_RATELIMIT_USER': '100/day', diff --git a/docs/configuration.rst b/docs/configuration.rst index 73b56204..c736f30b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -39,7 +39,7 @@ address. The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses that are allowed to send emails from any existing address (spoofing the sender). -The ``AUTH_RATELIMIT_IP`` (default: 10/hour) holds a security setting for fighting +The ``AUTH_RATELIMIT_IP`` (default: 60/hour) holds a security setting for fighting attackers that waste server ressources by trying to guess user passwords (typically using a password spraying attack). The value defines the limit of authentication attempts that will be processed on non-existing accounts for a specific IP subnet From 24aadf2f520eca1d61f8bd98550b64cccf736a9e Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Fri, 24 Sep 2021 10:07:41 +0200 Subject: [PATCH 05/27] ensure we log when the rate limiter hits --- core/admin/mailu/limiter.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index 70b09f21..88319012 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -47,7 +47,10 @@ class LimitWraperFactory(object): def should_rate_limit_ip(self, ip): limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip') client_network = utils.extract_network_from_ip(ip) - return self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) + is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) + if is_rate_limited: + app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.') + return is_rate_limited def rate_limit_ip(self, ip): if ip != app.config['WEBMAIL_ADDRESS']: @@ -58,7 +61,10 @@ class LimitWraperFactory(object): def should_rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') - return self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username) + is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username) + if is_rate_limited: + 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): limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') From ac496eed19955effbd262eaeb71f043a523061c4 Mon Sep 17 00:00:00 2001 From: Diman0 Date: Fri, 24 Sep 2021 12:57:17 +0200 Subject: [PATCH 06/27] Update setup with new rate limit config vars. --- setup/flavors/compose/mailu.env | 11 ++++++++--- setup/templates/steps/config.html | 14 +++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env index 0ba39019..fd95b725 100644 --- a/setup/flavors/compose/mailu.env +++ b/setup/flavors/compose/mailu.env @@ -29,9 +29,14 @@ POSTMASTER={{ postmaster }} # Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) TLS_FLAVOR={{ tls_flavor }} -# Authentication rate limit (per /24 on ipv4 and /56 on ipv6) -{% if auth_ratelimit_pm > '0' %} -AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute +# Authentication rate limit per IP (per /24 on ipv4 and /56 on ipv6) +{% if auth_ratelimit_ip > '0' %} +AUTH_RATELIMIT_IP={{ auth_ratelimit_ip }}/hour +{% endif %} + +# Authentication rate limit per user (per /24 on ipv4 and /56 on ipv6) +{% if auth_ratelimit_user > '0' %} +AUTH_RATELIMIT_USER={{ auth_ratelimit_user }}/day {% endif %} # Opt-out of statistics, replace with "True" to opt out diff --git a/setup/templates/steps/config.html b/setup/templates/steps/config.html index f532f757..4b4bb281 100644 --- a/setup/templates/steps/config.html +++ b/setup/templates/steps/config.html @@ -48,10 +48,18 @@ Or in plain english: if receivers start to classify your mail as spam, this post
- + -

/ minute +

/ hour +

+
+ +
+ + +

/ day

From 862fdda55b9f13f4e5bc82f525441a7716210bf6 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Fri, 24 Sep 2021 13:35:41 +0200 Subject: [PATCH 07/27] Tweak the wording --- setup/flavors/compose/mailu.env | 2 +- setup/templates/steps/config.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env index fd95b725..60516013 100644 --- a/setup/flavors/compose/mailu.env +++ b/setup/flavors/compose/mailu.env @@ -34,7 +34,7 @@ TLS_FLAVOR={{ tls_flavor }} AUTH_RATELIMIT_IP={{ auth_ratelimit_ip }}/hour {% endif %} -# Authentication rate limit per user (per /24 on ipv4 and /56 on ipv6) +# Authentication rate limit per user (regardless of the source-IP) {% if auth_ratelimit_user > '0' %} AUTH_RATELIMIT_USER={{ auth_ratelimit_user }}/day {% endif %} diff --git a/setup/templates/steps/config.html b/setup/templates/steps/config.html index 4b4bb281..74a45800 100644 --- a/setup/templates/steps/config.html +++ b/setup/templates/steps/config.html @@ -48,7 +48,7 @@ Or in plain english: if receivers start to classify your mail as spam, this post
- +

/ hour @@ -56,7 +56,7 @@ Or in plain english: if receivers start to classify your mail as spam, this post

- +

/ day From 464a117e9f19f42af01a47f19c7cd488a88db959 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Fri, 24 Sep 2021 13:37:00 +0200 Subject: [PATCH 08/27] this should be changed too --- setup/flavors/compose/mailu.env | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env index 60516013..3e53e7d2 100644 --- a/setup/flavors/compose/mailu.env +++ b/setup/flavors/compose/mailu.env @@ -155,9 +155,8 @@ DOMAIN_REGISTRATION=true # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME={{ compose_project_name or 'mailu' }} -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME={{ password_scheme or 'PBKDF2' }} +# Number of rounds used by the password hashing scheme +CREDENTIAL_ROUNDS=12 # Header to take the real ip from REAL_IP_HEADER={{ real_ip_header }} From 57b0dd490c55dd28f03a2e5fa92dd6385ed6d111 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:29:17 +0200 Subject: [PATCH 09/27] Initialize user_email in all cases --- core/admin/mailu/internal/nginx.py | 1 + 1 file changed, 1 insertion(+) diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index ff7070ab..7613c48a 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -89,6 +89,7 @@ def handle_authentication(headers): # we need to manually decode. raw_user_email = urllib.parse.unquote(headers["Auth-User"]) raw_password = urllib.parse.unquote(headers["Auth-Pass"]) + user_email = 'invalid' try: user_email = raw_user_email.encode("iso8859-1").decode("utf8") password = raw_password.encode("iso8859-1").decode("utf8") From 265ab7b5afd4be97810f8c3ea9fb9608654c83d8 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:31:09 +0200 Subject: [PATCH 10/27] Remove PASSWORD_SCHEME from test envs --- tests/compose/core/mailu.env | 4 ---- tests/compose/fetchmail/mailu.env | 4 ---- tests/compose/filters/mailu.env | 4 ---- tests/compose/rainloop/mailu.env | 4 ---- tests/compose/roundcube/mailu.env | 4 ---- tests/compose/webdav/mailu.env | 4 ---- 6 files changed, 24 deletions(-) diff --git a/tests/compose/core/mailu.env b/tests/compose/core/mailu.env index a78515b8..254c3c7d 100644 --- a/tests/compose/core/mailu.env +++ b/tests/compose/core/mailu.env @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/fetchmail/mailu.env b/tests/compose/fetchmail/mailu.env index afb57751..a015eaa8 100644 --- a/tests/compose/fetchmail/mailu.env +++ b/tests/compose/fetchmail/mailu.env @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/filters/mailu.env b/tests/compose/filters/mailu.env index 4c8c219d..1b4fb93d 100644 --- a/tests/compose/filters/mailu.env +++ b/tests/compose/filters/mailu.env @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/rainloop/mailu.env b/tests/compose/rainloop/mailu.env index 1f5fce0c..944dd376 100644 --- a/tests/compose/rainloop/mailu.env +++ b/tests/compose/rainloop/mailu.env @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/roundcube/mailu.env b/tests/compose/roundcube/mailu.env index ba153ac2..9db7fcbe 100644 --- a/tests/compose/roundcube/mailu.env +++ b/tests/compose/roundcube/mailu.env @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/webdav/mailu.env b/tests/compose/webdav/mailu.env index 939f453b..f1de013d 100644 --- a/tests/compose/webdav/mailu.env +++ b/tests/compose/webdav/mailu.env @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= From 4fff45bb30b69946a130d3047aad16cab5095953 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:31:33 +0200 Subject: [PATCH 11/27] Fix typo --- docs/configuration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index c736f30b..f4c3c255 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -40,7 +40,7 @@ address. The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses that are allowed to send emails from any existing address (spoofing the sender). The ``AUTH_RATELIMIT_IP`` (default: 60/hour) holds a security setting for fighting -attackers that waste server ressources by trying to guess user passwords (typically +attackers that waste server resources by trying to guess user passwords (typically using a password spraying attack). The value defines the limit of authentication attempts that will be processed on non-existing accounts for a specific IP subnet (as defined in ``AUTH_RATELIMIT_IP_V4_MASK`` and ``AUTH_RATELIMIT_IP_V6_MASK`` below). From 068170c0ff48206c866eb0011b3526c738c55e93 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:35:01 +0200 Subject: [PATCH 12/27] Use app instead of flask.current_app where possible --- core/admin/mailu/utils.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 9817287a..4711ff20 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -59,14 +59,14 @@ def has_dane_record(domain, timeout=10): # If the DNSSEC data is invalid and the DNS resolver is DNSSEC enabled # we will receive this non-specific exception. The safe behaviour is to # accept to defer the email. - flask.current_app.logger.warn(f'Unable to lookup the TLSA record for {domain}. Is the DNSSEC zone okay on https://dnsviz.net/d/{domain}/dnssec/?') - return flask.current_app.config['DEFER_ON_TLS_ERROR'] + app.logger.warn(f'Unable to lookup the TLSA record for {domain}. Is the DNSSEC zone okay on https://dnsviz.net/d/{domain}/dnssec/?') + return app.config['DEFER_ON_TLS_ERROR'] except dns.exception.Timeout: - flask.current_app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).') + app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).') except dns.resolver.NXDOMAIN: pass # this is expected, not TLSA record is fine except Exception as e: - flask.current_app.logger.error(f'Error while looking up the TLSA record for {domain} {e}') + app.logger.error(f'Error while looking up the TLSA record for {domain} {e}') pass # Rate limiter @@ -88,8 +88,8 @@ babel = flask_babel.Babel() def get_locale(): """ selects locale for translation """ language = flask.session.get('language') - if not language in flask.current_app.config.translations: - language = flask.request.accept_languages.best_match(flask.current_app.config.translations.keys()) + if not language in app.config.translations: + language = flask.request.accept_languages.best_match(app.config.translations.keys()) flask.session['language'] = language return language @@ -486,7 +486,7 @@ class MailuSessionExtension: with cleaned.get_lock(): if not cleaned.value: cleaned.value = True - flask.current_app.logger.info('cleaning session store') + app.logger.info('cleaning session store') MailuSessionExtension.cleanup_sessions(app) app.before_first_request(cleaner) From 2dd9ea150616ab84517c0b6d248499eb1b62e3b3 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:36:49 +0200 Subject: [PATCH 13/27] simplify --- core/admin/mailu/internal/views/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 457efd4b..0ed967ba 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -24,7 +24,7 @@ def nginx_authentication(): for key, value in headers.items(): response.headers[key] = str(value) is_valid_user = False - if "Auth-User-Exists" in response.headers and response.headers["Auth-User-Exists"]: + if response.headers.get("Auth-User-Exists"): username = response.headers["Auth-User"] if utils.limiter.should_rate_limit_user(username, client_ip): # FIXME could be done before handle_authentication() @@ -69,7 +69,7 @@ def user_authentication(): def basic_authentication(): """ Tries to authenticate using the Authorization header. """ - client_ip = flask.request.headers["X-Real-IP"] if 'X-Real-IP' in flask.request.headers else flask.request.remote_addr + client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) if utils.limiter.should_rate_limit_ip(client_ip): response = flask.Response(status=401) response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit from one source exceeded"' From 3bda8368e41f5cbce30cc796896823eed75eef47 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:39:34 +0200 Subject: [PATCH 14/27] simplify the Auth-Status check --- core/admin/mailu/internal/views/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 0ed967ba..1ea2828a 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -36,9 +36,9 @@ def nginx_authentication(): response.headers['Auth-Wait'] = '3' return response is_valid_user = True - if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"): + if headers.get('Auth-Status') != 'OK': utils.limiter.rate_limit_user(username, client_ip) if is_valid_user else rate_limit_ip(client_ip) - elif ("Auth-Status" in headers) and (headers["Auth-Status"] == "OK"): + elif headers["Auth-Status"] == "OK": utils.limiter.exempt_ip_from_ratelimits(client_ip) return response From de276a682285a4f568fb35d11a3f93f5d2785901 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:45:10 +0200 Subject: [PATCH 15/27] Simplify extract_network_from_ip --- core/admin/mailu/utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 4711ff20..db760280 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -74,12 +74,10 @@ limiter = limiter.LimitWraperFactory() def extract_network_from_ip(ip): n = ipaddress.ip_network(ip) - if isinstance(n, ipaddress.IPv4Network): + if n.version == 4: return str(n.supernet(prefixlen_diff=(32-int(app.config["AUTH_RATELIMIT_IP_V4_MASK"]))).network_address) - elif isinstance(n, ipaddress.IPv6Network): + else: return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address) - else: # not sure what to do with it - return ip # Application translation babel = flask_babel.Babel() From abaa2e8cc3e07e5f5a759072bdc83148f44438da Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:46:21 +0200 Subject: [PATCH 16/27] simplify client_ip --- core/admin/mailu/ui/views/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/ui/views/base.py b/core/admin/mailu/ui/views/base.py index 05211804..515d6055 100644 --- a/core/admin/mailu/ui/views/base.py +++ b/core/admin/mailu/ui/views/base.py @@ -14,7 +14,7 @@ def index(): @ui.route('/login', methods=['GET', 'POST']) def login(): - client_ip = flask.request.headers["X-Real-IP"] if 'X-Real-IP' in flask.request.headers else flask.request.remote_addr + client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) form = forms.LoginForm() if form.validate_on_submit(): device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit')) From e14d2e7c03eca6adecff3afe6e2de0d7247c399f Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 09:48:08 +0200 Subject: [PATCH 17/27] Error out explictely if Auth-Port isn't set --- core/admin/mailu/internal/nginx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 7613c48a..4df8f55a 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -77,7 +77,7 @@ def handle_authentication(headers): # Authenticated user elif method == "plain": is_valid_user = False - if 'Auth-Port' in headers and int(urllib.parse.unquote(headers["Auth-Port"])) == 25: + if headers["Auth-Port"] == '25': return { "Auth-Status": "AUTH not supported", "Auth-Error-Code": "502 5.5.1", From 99c81c20a721d8b379e4af0399328c2a8cc0238a Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 10:26:38 +0200 Subject: [PATCH 18/27] Introduce AUTH_RATELIMIT_EXEMPTION This disables rate limiting on specific CIDRs --- core/admin/mailu/configuration.py | 1 + core/admin/mailu/limiter.py | 2 +- core/admin/mailu/utils.py | 6 ++++++ docs/configuration.rst | 4 ++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 37f84b7c..6a3641fe 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -40,6 +40,7 @@ DEFAULT_CONFIG = { 'AUTH_RATELIMIT_IP_V4_MASK': 24, 'AUTH_RATELIMIT_IP_V6_MASK': 56, 'AUTH_RATELIMIT_USER': '100/day', + 'AUTH_RATELIMIT_EXEMPTION': '', 'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400, 'DISABLE_STATISTICS': False, # Mail settings diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index 88319012..ddaa07b3 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -39,7 +39,7 @@ class LimitWraperFactory(object): return LimitWrapper(self.limiter, limits.parse(limit), *args) def is_subject_to_rate_limits(self, ip): - return not (self.storage.get(f'exempt-{ip}') > 0) + return False if utils.is_subject_to_rate_limits(ip) else not (self.storage.get(f'exempt-{ip}') > 0) def exempt_ip_from_ratelimits(self, ip): self.storage.incr(f'exempt-{ip}', app.config["AUTH_RATELIMIT_EXEMPTION_LENGTH"], True) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index db760280..d2bcc7a3 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -79,6 +79,12 @@ def extract_network_from_ip(ip): else: return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address) +def is_exempt_from_ratelimits(ip): + for range in [net.strip() for net in app.config['AUTH_RATELIMIT_EXEMPTION'].split(',')]: + if ipaddress.ip_address(ip) in ipaddress.ip_network(ip, False): + return False + return True + # Application translation babel = flask_babel.Babel() diff --git a/docs/configuration.rst b/docs/configuration.rst index 75f397c7..f5bd9582 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -55,6 +55,10 @@ after a successful login for which a specific IP address is exempted from rate l This ensures that users behind a NAT don't get locked out when a single client is misconfigured... but also potentially allow for users to attack each-other. +The ``AUTH_RATELIMIT_EXEMPTION`` (default: '') is a comma separated list of network +CIDRs that won't be subject to any form of rate limiting. Specifying ``0.0.0.0/0, ::/0`` +there is a good way to disable rate limiting altogether. + The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to ``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`. From c5bd82650fbe6bf26fafb2e9af454f0729574e1d Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 10:30:57 +0200 Subject: [PATCH 19/27] doh --- 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 ddaa07b3..3bc65f4f 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -39,7 +39,7 @@ class LimitWraperFactory(object): return LimitWrapper(self.limiter, limits.parse(limit), *args) def is_subject_to_rate_limits(self, ip): - return False if utils.is_subject_to_rate_limits(ip) else not (self.storage.get(f'exempt-{ip}') > 0) + return False if utils.is_exempt_from_ratelimits(ip) else not (self.storage.get(f'exempt-{ip}') > 0) def exempt_ip_from_ratelimits(self, ip): self.storage.incr(f'exempt-{ip}', app.config["AUTH_RATELIMIT_EXEMPTION_LENGTH"], True) From 94bbed9746333dc81f136dbf2b8675cd1e84dab5 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 10:39:43 +0200 Subject: [PATCH 20/27] Ensure we have the right IP --- core/admin/mailu/internal/views/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 1ea2828a..2c9a5d31 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -86,7 +86,7 @@ def basic_authentication(): response.headers['Retry-After'] = '60' return response user = models.User.query.get(user_email) - if user and nginx.check_credentials(user, password.decode('utf-8'), flask.request.remote_addr, "web"): + if user and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web"): response = flask.Response() response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "") utils.limiter.exempt_ip_from_ratelimits(client_ip) From 98742268e6bae14dc778ecad69812068bc2038d5 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 15:12:20 +0200 Subject: [PATCH 21/27] Make it more readable --- core/admin/mailu/internal/views/auth.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 2c9a5d31..c5cd9e28 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -36,10 +36,12 @@ def nginx_authentication(): response.headers['Auth-Wait'] = '3' return response is_valid_user = True - if headers.get('Auth-Status') != 'OK': - utils.limiter.rate_limit_user(username, client_ip) if is_valid_user else rate_limit_ip(client_ip) - elif headers["Auth-Status"] == "OK": + 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) + else: + rate_limit_ip(client_ip) return response @internal.route("/auth/admin") From 19b784b1982789f4548874a892a84c3e1828ab14 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 15:18:41 +0200 Subject: [PATCH 22/27] Parse the network configuration only once thanks @ghostwheel42 --- core/admin/mailu/configuration.py | 1 + core/admin/mailu/utils.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 6a3641fe..a4a42d03 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -152,6 +152,7 @@ class ConfigManager(dict): self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] + self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(net.strip(), False) for net in config['AUTH_RATELIMIT_EXEMPTION'].split(',')) self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] # update the app config itself diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index d2bcc7a3..dda927b0 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -80,10 +80,8 @@ def extract_network_from_ip(ip): return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address) def is_exempt_from_ratelimits(ip): - for range in [net.strip() for net in app.config['AUTH_RATELIMIT_EXEMPTION'].split(',')]: - if ipaddress.ip_address(ip) in ipaddress.ip_network(ip, False): - return False - return True + ip = ipaddress.ip_address(ip) + return any(ip in cidr for cidr in app.config['AUTH_RATELIMIT_EXEMPTION']) # Application translation babel = flask_babel.Babel() From 5b72c32251daaa283314e80de236bc47cac5a48f Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 15:44:26 +0200 Subject: [PATCH 23/27] Doh --- core/admin/mailu/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index a4a42d03..2136e624 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -152,7 +152,7 @@ class ConfigManager(dict): self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] - self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(net.strip(), False) for net in config['AUTH_RATELIMIT_EXEMPTION'].split(',')) + self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(net.strip(), False) for net in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] # update the app config itself From e8871dd77f12e00e63ab6c6c1355ee03860afa9d Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 16:06:13 +0200 Subject: [PATCH 24/27] doh --- core/admin/mailu/configuration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 2136e624..c1444ee5 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -2,6 +2,7 @@ import os from datetime import timedelta from socrate import system +import ipaddress DEFAULT_CONFIG = { # Specific to the admin UI From 34497cff209fa8c3cbf7a14756e0c6bff6660cea Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 16:35:48 +0200 Subject: [PATCH 25/27] doh --- core/admin/mailu/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index c1444ee5..4d4ee3e9 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -153,7 +153,7 @@ class ConfigManager(dict): self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] - self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(net.strip(), False) for net in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) + self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(net.strip(), False) for net in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if self.config['AUTH_RATELIMIT_EXEMPTION'] else set() self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] # update the app config itself From 1c6165213cc1420b16384a8c30017b2a35b33ff6 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 16:54:56 +0200 Subject: [PATCH 26/27] better that way --- core/admin/mailu/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 4d4ee3e9..352bf431 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -153,7 +153,7 @@ class ConfigManager(dict): self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] - self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(net.strip(), False) for net in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if self.config['AUTH_RATELIMIT_EXEMPTION'] else set() + self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if cidr.strip()) self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] # update the app config itself From 693b578bbb5ee60f5286405b54c38b783db6062a Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 16 Oct 2021 17:24:12 +0200 Subject: [PATCH 27/27] The second strip isn't necessary --- core/admin/mailu/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 352bf431..d33efa12 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -153,7 +153,7 @@ class ConfigManager(dict): self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] - self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if cidr.strip()) + self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if cidr) self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] # update the app config itself