Implement rate-limits

master
Florent Daigniere 3 years ago
parent 4c5c6c3b5f
commit 89ea51d570

@ -28,6 +28,7 @@ def create_app_from_config(config):
utils.proxy.init_app(app) utils.proxy.init_app(app)
utils.migrate.init_app(app, models.db) 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() 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 # Initialize list of translations

@ -36,8 +36,11 @@ DEFAULT_CONFIG = {
'TLS_FLAVOR': 'cert', 'TLS_FLAVOR': 'cert',
'INBOUND_TLS_ENFORCE': False, 'INBOUND_TLS_ENFORCE': False,
'DEFER_ON_TLS_ERROR': True, 'DEFER_ON_TLS_ERROR': True,
'AUTH_RATELIMIT': '1000/minute;10000/hour', 'AUTH_RATELIMIT_IP': '10/hour',
'AUTH_RATELIMIT_SUBNET': False, '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, 'DISABLE_STATISTICS': False,
# Mail settings # Mail settings
'DMARC_RUA': None, 'DMARC_RUA': None,

@ -19,6 +19,11 @@ STATUSES = {
"encryption": ("Must issue a STARTTLS command first", { "encryption": ("Must issue a STARTTLS command first", {
"smtp": "530 5.7.0" "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): def check_credentials(user, password, ip, protocol=None):
@ -71,8 +76,9 @@ def handle_authentication(headers):
} }
# Authenticated user # Authenticated user
elif method == "plain": elif method == "plain":
is_valid_user = False
service_port = int(urllib.parse.unquote(headers["Auth-Port"])) service_port = int(urllib.parse.unquote(headers["Auth-Port"]))
if service_port == 25: if 'Auth-Port' in headers and service_port == 25:
return { return {
"Auth-Status": "AUTH not supported", "Auth-Status": "AUTH not supported",
"Auth-Error-Code": "502 5.5.1", "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}') app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}')
else: else:
user = models.User.query.get(user_email) user = models.User.query.get(user_email)
is_valid_user = True
ip = urllib.parse.unquote(headers["Client-Ip"]) ip = urllib.parse.unquote(headers["Client-Ip"])
if check_credentials(user, password, ip, protocol): if check_credentials(user, password, ip, protocol):
server, port = get_server(headers["Auth-Protocol"], True) server, port = get_server(headers["Auth-Protocol"], True)
return { return {
"Auth-Status": "OK", "Auth-Status": "OK",
"Auth-Server": server, "Auth-Server": server,
"Auth-User": user_email,
"Auth-User-Exists": is_valid_user,
"Auth-Port": port "Auth-Port": port
} }
status, code = get_status(protocol, "authentication") status, code = get_status(protocol, "authentication")
return { return {
"Auth-Status": status, "Auth-Status": status,
"Auth-Error-Code": code, "Auth-Error-Code": code,
"Auth-User": user_email,
"Auth-User-Exists": is_valid_user,
"Auth-Wait": 0 "Auth-Wait": 0
} }
# Unexpected # Unexpected

@ -5,19 +5,17 @@ from flask import current_app as app
import flask import flask
import flask_login import flask_login
import base64 import base64
import ipaddress
@internal.route("/auth/email") @internal.route("/auth/email")
def nginx_authentication(): def nginx_authentication():
""" Main authentication endpoint for Nginx email server """ 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"] 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 = flask.Response()
response.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded' response.headers['Auth-Status'] = status
response.headers['Auth-Error-Code'] = '451 4.3.2' response.headers['Auth-Error-Code'] = code
if int(flask.request.headers['Auth-Login-Attempt']) < 10: if int(flask.request.headers['Auth-Login-Attempt']) < 10:
response.headers['Auth-Wait'] = '3' response.headers['Auth-Wait'] = '3'
return response return response
@ -25,14 +23,25 @@ def nginx_authentication():
response = flask.Response() response = flask.Response()
for key, value in headers.items(): for key, value in headers.items():
response.headers[key] = str(value) 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"): if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"):
limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"]) != 'False' utils.limiter.rate_limit_user(username, client_ip) if is_valid_user else rate_limit_ip(client_ip)
subnet = ipaddress.ip_network(app.config["SUBNET"]) elif ("Auth-Status" in headers) and (headers["Auth-Status"] == "OK"):
if limit_subnet or ipaddress.ip_address(client_ip) not in subnet: utils.limiter.exempt_ip_from_ratelimits(client_ip)
limiter.hit(flask.request.headers["Client-Ip"])
return response return response
@internal.route("/auth/admin") @internal.route("/auth/admin")
def admin_authentication(): def admin_authentication():
""" Fails if the user is not an authenticated admin. """ Fails if the user is not an authenticated admin.
@ -60,15 +69,29 @@ def user_authentication():
def basic_authentication(): def basic_authentication():
""" Tries to authenticate using the Authorization header. """ 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") authorization = flask.request.headers.get("Authorization")
if authorization and authorization.startswith("Basic "): if authorization and authorization.startswith("Basic "):
encoded = authorization.replace("Basic ", "") encoded = authorization.replace("Basic ", "")
user_email, password = base64.b64decode(encoded).split(b":", 1) user_email, password = base64.b64decode(encoded).split(b":", 1)
user = models.User.query.get(user_email.decode("utf8")) user_email = user_email.decode("utf8")
if nginx.check_credentials(user, password.decode('utf-8'), flask.request.remote_addr, "web"): 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 = flask.Response()
response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "") response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "")
utils.limiter.exempt_ip_from_ratelimits(client_ip)
return response 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 = flask.Response(status=401)
response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"' response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"'
return response return response

@ -1,7 +1,12 @@
from mailu import utils
from flask import current_app as app
import base64
import limits import limits
import limits.storage import limits.storage
import limits.strategies import limits.strategies
import hmac
import secrets
class LimitWrapper(object): class LimitWrapper(object):
""" Wraps a limit by providing the storage, item and identifiers """ Wraps a limit by providing the storage, item and identifiers
@ -32,3 +37,52 @@ class LimitWraperFactory(object):
def get_limiter(self, limit, *args): def get_limiter(self, limit, *args):
return LimitWrapper(self.limiter, limits.parse(limit), *args) 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}'

@ -1,4 +1,4 @@
from mailu import models from mailu import models, utils
from mailu.ui import ui, forms, access from mailu.ui import ui, forms, access
from flask import current_app as app from flask import current_app as app
@ -14,16 +14,28 @@ def index():
@ui.route('/login', methods=['GET', 'POST']) @ui.route('/login', methods=['GET', 'POST'])
def login(): 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() form = forms.LoginForm()
if form.validate_on_submit(): 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: if user:
flask.session.regenerate() flask.session.regenerate()
flask_login.login_user(user) flask_login.login_user(user)
endpoint = flask.request.args.get('next', '.index') 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')) 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: 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') flask.flash('Wrong e-mail or password', 'error')
return flask.render_template('login.html', form=form) return flask.render_template('login.html', form=form)

@ -17,10 +17,12 @@ from multiprocessing import Value
from mailu import limiter from mailu import limiter
from flask import current_app as app
import flask import flask
import flask_login import flask_login
import flask_migrate import flask_migrate
import flask_babel import flask_babel
import ipaddress
import redis import redis
from flask.sessions import SessionMixin, SessionInterface from flask.sessions import SessionMixin, SessionInterface
@ -70,6 +72,15 @@ def has_dane_record(domain, timeout=10):
# Rate limiter # Rate limiter
limiter = limiter.LimitWraperFactory() 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 # Application translation
babel = flask_babel.Babel() babel = flask_babel.Babel()

@ -217,6 +217,7 @@ http {
location /internal { location /internal {
internal; internal;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization; proxy_pass_header Authorization;
proxy_pass http://$admin; proxy_pass http://$admin;

@ -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 ``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 The ``AUTH_RATELIMIT_IP`` (default: 10/hour) holds a security setting for fighting
try to guess user passwords. The value is the limit of failed authentication attempts attackers that waste server ressources by trying to guess user passwords (typically
that a single IP address can perform against IMAP, POP and SMTP authentication endpoints. 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`` The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting
rules does also apply to auth requests coming from ``SUBNET``, especially for the webmail. attackers that attempt to guess a user's password (typically using a password
If you disable this, ensure that the rate limit on the webmail is enforced in a different bruteforce attack). The value defines the limit of authentication attempts allowed
way (e.g. roundcube plug-in), otherwise an attacker can simply bypass the limit using webmail. 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 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`. ``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`.

@ -100,6 +100,9 @@ https://github.com/moby/moby/issues/25526#issuecomment-336363408
### Don't create an open relay ! ### 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 :-( 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 ## Scalability
- smtp and imap are scalable - smtp and imap are scalable

@ -29,7 +29,7 @@ POSTMASTER={{ postmaster }}
# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) # Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt)
TLS_FLAVOR={{ tls_flavor }} 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' %} {% if auth_ratelimit_pm > '0' %}
AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute
{% endif %} {% endif %}

@ -0,0 +1 @@
Make the rate limit apply to a subnet rather than a specific IP (/24 for v4 and /56 for v6)

@ -0,0 +1 @@
Fix rate-limiting on /webdav/

@ -0,0 +1 @@
Refactor the rate limiter to ensure that it performs as intented.
Loading…
Cancel
Save