1987: Enhancement to the rate limits r=mergify[bot] a=nextgens

## What type of PR?

enhancement

## What does this PR do?

Turn the rate-limiters into something useful (that won't fire for no reason).

- fix rate-limiting on /webdav/
- it changes the rate-limiting behaviour from limiting a single IP address to a subnet of a reasonable size (/24 on v4 and /56 on v6 both are now configurable) : AUTH_RATELIMIT_IP / AUTH_RATELIMIT_IP_V4_MASK / AUTH_RATELIMIT_IP_V6_MASK
- It ensures we only use IP-based rate-limits for attempts on accounts that do not exist
- it creates a new rate limit preventing attackers from targetting a specific user account (separate from what's above) : AUTH_RATELIMIT_USER
- it introduces a rate limiting exemption mechanism whereby, upon authentication, users will see their source-ip address being exempt for a specific amount of time AUTH_RATELIMIT_EXEMPTION_LENGTH. A similar mechanism is available for web-based sessions (see below)
- It introduces in AUTH_RATELIMIT_EXEMPTION a comma separated list of network CIDRs that will be exempt from both types of rate limiting
- it implements device-tokens, as described on https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies to ensure that genuine users aren't locked-out by a malicious attacker abusing the rate-limit feature.

Things that could be improved include:
- the IP-based rate limiter flags attempts against "non-existing" accounts: it could go further and flag the number of unique non-existing accounts attempted (to prevent the case of a user making a typo in his MUA configuration)
- the IP address exemption mechanism doesn't pin the exemption to a specific username: any real user can trivially bypass the rate limits (and attempt to brute-force someone else's account)

### Related issue(s)
- close #1926
- close #1745 
- close #1915


## Prerequisites
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.

- [x] In case of feature or enhancement: documentation updated accordingly
- [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file.


Co-authored-by: Florent Daigniere <nextgens@freenetproject.org>
Co-authored-by: Diman0 <diman@huisman.xyz>
Co-authored-by: Florent Daigniere <nextgens@users.noreply.github.com>
master
bors[bot] 3 years ago committed by GitHub
commit a1192d8039
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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()
app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest()

@ -2,6 +2,7 @@ import os
from datetime import timedelta
from socrate import system
import ipaddress
DEFAULT_CONFIG = {
# Specific to the admin UI
@ -36,8 +37,12 @@ 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': '60/hour',
'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
'DMARC_RUA': None,
@ -148,6 +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)
self.config['HOSTNAMES'] = ','.join(hostnames)
self.config['HOSTNAME'] = hostnames[0]
# update the app config itself

@ -20,6 +20,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):
@ -72,8 +77,8 @@ def handle_authentication(headers):
}
# Authenticated user
elif method == "plain":
service_port = int(urllib.parse.unquote(headers["Auth-Port"]))
if service_port == 25:
is_valid_user = False
if headers["Auth-Port"] == '25':
return {
"Auth-Status": "AUTH not supported",
"Auth-Error-Code": "502 5.5.1",
@ -85,29 +90,37 @@ 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")
ip = urllib.parse.unquote(headers["Client-Ip"])
except:
app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}')
else:
try:
user = models.User.query.get(user_email)
is_valid_user = True
except sqlalchemy.exc.StatementError as exc:
exc = str(exc).split('\n', 1)[0]
app.logger.warn(f'Invalid user {user_email!r}: {exc}')
else:
if check_credentials(user, password, urllib.parse.unquote(headers["Client-Ip"]), protocol):
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

@ -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,27 @@ def nginx_authentication():
response = flask.Response()
for key, value in headers.items():
response.headers[key] = str(value)
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"])
is_valid_user = False
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()
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 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")
def admin_authentication():
""" Fails if the user is not an authenticated admin.
@ -60,15 +71,29 @@ def user_authentication():
def basic_authentication():
""" Tries to authenticate using the Authorization header.
"""
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"'
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'), 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)
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

@ -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,59 @@ class LimitWraperFactory(object):
self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage)
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 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)
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)
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']:
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')
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')
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 flask import current_app as app
@ -14,16 +14,30 @@ def index():
@ui.route('/login', methods=['GET', 'POST'])
def login():
client_ip = flask.request.headers.get('X-Real-IP', 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'))
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)

@ -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
@ -57,19 +59,30 @@ 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
limiter = limiter.LimitWraperFactory()
def extract_network_from_ip(ip):
n = ipaddress.ip_network(ip)
if n.version == 4:
return str(n.supernet(prefixlen_diff=(32-int(app.config["AUTH_RATELIMIT_IP_V4_MASK"]))).network_address)
else:
return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address)
def is_exempt_from_ratelimits(ip):
ip = ipaddress.ip_address(ip)
return any(ip in cidr for cidr in app.config['AUTH_RATELIMIT_EXEMPTION'])
# Application translation
babel = flask_babel.Babel()
@ -77,8 +90,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
@ -475,7 +488,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)

@ -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;

@ -39,14 +39,25 @@ 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: 60/hour) holds a security setting for fighting
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).
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 ``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`.

@ -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

@ -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 source IP address)
{% 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 (regardless of the source-IP)
{% if auth_ratelimit_user > '0' %}
AUTH_RATELIMIT_USER={{ auth_ratelimit_user }}/day
{% endif %}
# Opt-out of statistics, replace with "True" to opt out
@ -150,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 }}

@ -48,10 +48,18 @@ Or in plain english: if receivers start to classify your mail as spam, this post
</div>
<div class="form-group">
<label>Authentication rate limit (per source IP address)</label>
<label>Authentication rate limit per IP for failed login attempts or non-existing accounts</label>
<!-- Validates number input only -->
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_pm"
value="10000" required > / minute
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_ip"
value="60" required > / hour
</p>
</div>
<div class="form-group">
<label>Authentication rate limit per user</label>
<!-- Validates number input only -->
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_user"
value="100" required > / day
</p>
</div>

@ -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=

@ -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=

@ -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=

@ -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=

@ -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=

@ -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=

@ -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.

@ -0,0 +1 @@
Log authentication attempts on the admin portal
Loading…
Cancel
Save