Merge branch 'master' into new-release-mailu

This commit is contained in:
Dimitri Huisman
2023-04-04 08:00:25 +00:00
19 changed files with 83 additions and 34 deletions

View File

@@ -44,7 +44,7 @@ DEFAULT_CONFIG = {
'AUTH_RATELIMIT_IP': '5/hour', 'AUTH_RATELIMIT_IP': '5/hour',
'AUTH_RATELIMIT_IP_V4_MASK': 24, 'AUTH_RATELIMIT_IP_V4_MASK': 24,
'AUTH_RATELIMIT_IP_V6_MASK': 48, 'AUTH_RATELIMIT_IP_V6_MASK': 48,
'AUTH_RATELIMIT_USER': '100/day', 'AUTH_RATELIMIT_USER': '50/day',
'AUTH_RATELIMIT_EXEMPTION': '', 'AUTH_RATELIMIT_EXEMPTION': '',
'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400, 'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400,
'DISABLE_STATISTICS': False, 'DISABLE_STATISTICS': False,

View File

@@ -85,6 +85,7 @@ def handle_authentication(headers):
raw_user_email = urllib.parse.unquote(headers["Auth-User"]) raw_user_email = urllib.parse.unquote(headers["Auth-User"])
raw_password = urllib.parse.unquote(headers["Auth-Pass"]) raw_password = urllib.parse.unquote(headers["Auth-Pass"])
user_email = 'invalid' user_email = 'invalid'
password = 'invalid'
try: try:
user_email = raw_user_email.encode("iso8859-1").decode("utf8") user_email = raw_user_email.encode("iso8859-1").decode("utf8")
password = raw_password.encode("iso8859-1").decode("utf8") password = raw_password.encode("iso8859-1").decode("utf8")
@@ -107,6 +108,7 @@ def handle_authentication(headers):
"Auth-Server": server, "Auth-Server": server,
"Auth-User": user_email, "Auth-User": user_email,
"Auth-User-Exists": is_valid_user, "Auth-User-Exists": is_valid_user,
"Auth-Password": password,
"Auth-Port": port "Auth-Port": port
} }
status, code = get_status(protocol, "authentication") status, code = get_status(protocol, "authentication")
@@ -115,6 +117,7 @@ def handle_authentication(headers):
"Auth-Error-Code": code, "Auth-Error-Code": code,
"Auth-User": user_email, "Auth-User": user_email,
"Auth-User-Exists": is_valid_user, "Auth-User-Exists": is_valid_user,
"Auth-Password": password,
"Auth-Wait": 0 "Auth-Wait": 0
} }
# Unexpected # Unexpected

View File

@@ -48,7 +48,7 @@ def nginx_authentication():
if headers.get("Auth-Status") == "OK": if headers.get("Auth-Status") == "OK":
utils.limiter.exempt_ip_from_ratelimits(client_ip) utils.limiter.exempt_ip_from_ratelimits(client_ip)
elif is_valid_user: elif is_valid_user:
utils.limiter.rate_limit_user(username, client_ip) utils.limiter.rate_limit_user(username, client_ip, password=response.headers.get('Auth-Password', None))
elif not is_from_webmail: elif not is_from_webmail:
utils.limiter.rate_limit_ip(client_ip, username) utils.limiter.rate_limit_ip(client_ip, username)
return response return response

View File

@@ -68,9 +68,13 @@ class LimitWraperFactory(object):
app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.') app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.')
return is_rate_limited return is_rate_limited
def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''):
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user')
if self.is_subject_to_rate_limits(ip): if self.is_subject_to_rate_limits(ip):
truncated_password = hmac.new(bytearray(username, 'utf-8'), bytearray(password, 'utf-8'), 'sha256').hexdigest()[-6:]
if password and (self.storage.get(f'dedup2-{username}-{truncated_password}') > 0):
return
self.storage.incr(f'dedup2-{username}-{truncated_password}', limits.parse(app.config['AUTH_RATELIMIT_USER']).GRANULARITY.seconds, True)
limiter.hit(device_cookie if device_cookie_name == username else username) limiter.hit(device_cookie if device_cookie_name == username else username)
""" Device cookies as described on: """ Device cookies as described on:

View File

@@ -59,7 +59,7 @@ def login():
flask.flash(msg, "error") flask.flash(msg, "error")
return response 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, username) utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username, form.pw.data) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip, username)
flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') flask.current_app.logger.warn(f'Login failed for {username} from {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, fields=fields) return flask.render_template('login.html', form=form, fields=fields)

View File

@@ -128,6 +128,12 @@ class UserPasswordForm(flask_wtf.FlaskForm):
pwned = fields.HiddenField(label='', default=-1) pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Update password')) submit = fields.SubmitField(_('Update password'))
class UserPasswordChangeForm(flask_wtf.FlaskForm):
current_pw = fields.PasswordField(_('Current password'), [validators.DataRequired()])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()])
pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Update password'))
class UserReplyForm(flask_wtf.FlaskForm): class UserReplyForm(flask_wtf.FlaskForm):
reply_enabled = fields.BooleanField(_('Enable automatic reply')) reply_enabled = fields.BooleanField(_('Enable automatic reply'))

View File

@@ -20,7 +20,7 @@
</a> </a>
</li> </li>
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ url_for('.user_password') }}" class="nav-link" role="menuitem"> <a href="{{ url_for('.user_password_change') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-lock"></i> <i class="nav-icon fa fa-lock"></i>
<p>{% trans %}Update password{% endtrans %}</p> <p>{% trans %}Update password{% endtrans %}</p>
</a> </a>

View File

@@ -99,18 +99,13 @@ def user_settings(user_email):
flask.url_for('.user_list', domain_name=user.domain.name)) flask.url_for('.user_list', domain_name=user.domain.name))
return flask.render_template('user/settings.html', form=form, user=user) return flask.render_template('user/settings.html', form=form, user=user)
def _process_password_change(form, user_email):
@ui.route('/user/password', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/password/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def user_password(user_email):
user_email_or_current = user_email or flask_login.current_user.email user_email_or_current = user_email or flask_login.current_user.email
user = models.User.query.get(user_email_or_current) or flask.abort(404) user = models.User.query.get(user_email_or_current) or flask.abort(404)
form = forms.UserPasswordForm()
if form.validate_on_submit(): if form.validate_on_submit():
if form.pw.data != form.pw2.data: if form.pw.data != form.pw2.data:
flask.flash('Passwords do not match', 'error') flask.flash('Passwords do not match', 'error')
else: elif user_email or models.User.login(user_email_or_current, form.current_pw.data):
if msg := utils.isBadOrPwned(form): if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error") flask.flash(msg, "error")
return flask.render_template('user/password.html', form=form, user=user) return flask.render_template('user/password.html', form=form, user=user)
@@ -121,8 +116,19 @@ def user_password(user_email):
if user_email: if user_email:
return flask.redirect(flask.url_for('.user_list', return flask.redirect(flask.url_for('.user_list',
domain_name=user.domain.name)) domain_name=user.domain.name))
else:
flask.flash('Wrong current password', 'error')
return flask.render_template('user/password.html', form=form, user=user) return flask.render_template('user/password.html', form=form, user=user)
@ui.route('/user/password', methods=['GET', 'POST'], defaults={'user_email': None})
@access.owner(models.User, 'user_email')
def user_password_change(user_email):
return _process_password_change(forms.UserPasswordChangeForm(), user_email)
@ui.route('/user/password/<path:user_email>', methods=['GET', 'POST'])
@access.domain_admin(models.User, 'user_email')
def user_password(user_email):
return _process_password_change(forms.UserPasswordForm(), user_email)
@ui.route('/user/reply', methods=['GET', 'POST'], defaults={'user_email': None}) @ui.route('/user/reply', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/reply/<path:user_email>', methods=['GET', 'POST']) @ui.route('/user/reply/<path:user_email>', methods=['GET', 'POST'])

View File

@@ -135,12 +135,6 @@ WEBSITE=https://mailu.io
# Advanced settings # Advanced settings
################################### ###################################
# Log driver for front service. Possible values:
# json-file (default)
# journald (On systemd platforms, useful for Fail2Ban integration)
# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker compose log` for front!)
LOG_DRIVER=json-file
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu

View File

@@ -9,7 +9,9 @@ services:
restart: always restart: always
env_file: .env env_file: .env
logging: logging:
driver: $LOG_DRIVER driver: journald
options:
tag: mailu-front
ports: ports:
- "$BIND_ADDRESS4:80:80" - "$BIND_ADDRESS4:80:80"
- "$BIND_ADDRESS4:443:443" - "$BIND_ADDRESS4:443:443"
@@ -43,6 +45,10 @@ services:
image: mailu/dovecot:$VERSION image: mailu/dovecot:$VERSION
restart: always restart: always
env_file: .env env_file: .env
logging:
driver: journald
options:
tag: mailu-imap
volumes: volumes:
- "$ROOT/mail:/mail" - "$ROOT/mail:/mail"
- "$ROOT/overrides/dovecot:/overrides:ro" - "$ROOT/overrides/dovecot:/overrides:ro"
@@ -53,6 +59,10 @@ services:
image: mailu/postfix:$VERSION image: mailu/postfix:$VERSION
restart: always restart: always
env_file: .env env_file: .env
logging:
driver: journald
options:
tag: mailu-smtp
volumes: volumes:
- "$ROOT/mailqueue:/queue" - "$ROOT/mailqueue:/queue"
- "$ROOT/overrides/postfix:/overrides:ro" - "$ROOT/overrides/postfix:/overrides:ro"
@@ -63,6 +73,10 @@ services:
image: mailu/rspamd:$VERSION image: mailu/rspamd:$VERSION
restart: always restart: always
env_file: .env env_file: .env
logging:
driver: journald
options:
tag: mailu-antispam
volumes: volumes:
- "$ROOT/filter:/var/lib/rspamd" - "$ROOT/filter:/var/lib/rspamd"
- "$ROOT/dkim:/dkim:ro" - "$ROOT/dkim:/dkim:ro"
@@ -88,6 +102,10 @@ services:
image: mailu/admin:$VERSION image: mailu/admin:$VERSION
restart: always restart: always
env_file: .env env_file: .env
logging:
driver: journald
options:
tag: mailu-admin
volumes: volumes:
- "$ROOT/data:/data" - "$ROOT/data:/data"
- "$ROOT/dkim:/dkim" - "$ROOT/dkim:/dkim"

View File

@@ -35,8 +35,6 @@ services:
image: mailu/nginx:$VERSION image: mailu/nginx:$VERSION
restart: always restart: always
env_file: .env env_file: .env
logging:
driver: $LOG_DRIVER
labels: # Traefik labels for simple reverse-proxying labels: # Traefik labels for simple reverse-proxying
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.port=80" - "traefik.port=80"

View File

@@ -47,10 +47,11 @@ accounts for a specific IP subnet as defined in
``AUTH_RATELIMIT_IP_V4_MASK`` (default: /24) and ``AUTH_RATELIMIT_IP_V4_MASK`` (default: /24) and
``AUTH_RATELIMIT_IP_V6_MASK`` (default: /48). ``AUTH_RATELIMIT_IP_V6_MASK`` (default: /48).
The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting The ``AUTH_RATELIMIT_USER`` (default: 50/day) holds a security setting for fighting
attackers that attempt to guess a user's password (typically using a password attackers that attempt to guess a user's password (typically using a password
bruteforce attack). The value defines the limit of authentication attempts allowed bruteforce attack). The value defines the limit of distinct authentication attempts
for any given account within a specific timeframe. allowed for any given account within a specific timeframe. Multiple attempts for the
same account with the same password only counts for one.
The ``AUTH_RATELIMIT_EXEMPTION_LENGTH`` (default: 86400) is the number of seconds 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. after a successful login for which a specific IP address is exempted from rate limits.

View File

@@ -579,8 +579,7 @@ down brute force attacks. The same applies to login attempts via the single sign
We *do* provide a possibility to export the logs from the ``front`` service and ``Admin`` service to the host. We *do* provide a possibility to export the logs from the ``front`` service and ``Admin`` service to the host.
The ``front`` container logs failed logon attempts on SMTP, IMAP and POP3. The ``front`` container logs failed logon attempts on SMTP, IMAP and POP3.
The ``Admin`` container logs failed logon attempt on the single sign on page. The ``Admin`` container logs failed logon attempt on the single sign on page.
For this you need to set ``LOG_DRIVER=journald`` or ``syslog``, depending on the log You will need to setup the proper Regex in the Fail2Ban configuration.
manager of the host. You will need to setup the proper Regex in the Fail2Ban configuration.
Below an example how to do so. Below an example how to do so.
If you use a reverse proxy in front of Mailu, it is vital to set the environment variables REAL_IP_HEADER and REAL_IP_FROM. If you use a reverse proxy in front of Mailu, it is vital to set the environment variables REAL_IP_HEADER and REAL_IP_FROM.

View File

@@ -26,7 +26,9 @@ services:
restart: always restart: always
env_file: {{ env }} env_file: {{ env }}
logging: logging:
driver: {{ log_driver or 'json-file' }} driver: journald
options:
tag: mailu-front
ports: ports:
{% for port in (80, 443, 25, 465, 587, 110, 995, 143, 993) %} {% for port in (80, 443, 25, 465, 587, 110, 995, 143, 993) %}
{% if bind4 %} {% if bind4 %}
@@ -38,8 +40,12 @@ services:
{% endfor %} {% endfor %}
networks: networks:
- default - default
{% if webmail_type != 'none' %}
- webmail - webmail
{% endif %}
{% if webdav_enabled %}
- radicale - radicale
{% endif %}
volumes: volumes:
- "{{ root }}/certs:/certs" - "{{ root }}/certs:/certs"
- "{{ root }}/overrides/nginx:/overrides:ro" - "{{ root }}/overrides/nginx:/overrides:ro"
@@ -62,6 +68,10 @@ services:
image: ${DOCKER_ORG:-ghcr.io/mailu}/${DOCKER_PREFIX:-}admin:${MAILU_VERSION:-{{ version }}} image: ${DOCKER_ORG:-ghcr.io/mailu}/${DOCKER_PREFIX:-}admin:${MAILU_VERSION:-{{ version }}}
restart: always restart: always
env_file: {{ env }} env_file: {{ env }}
logging:
driver: journald
options:
tag: mailu-admin
{% if not admin_enabled %} {% if not admin_enabled %}
ports: ports:
- 127.0.0.1:8080:80 - 127.0.0.1:8080:80
@@ -81,6 +91,10 @@ services:
image: ${DOCKER_ORG:-ghcr.io/mailu}/${DOCKER_PREFIX:-}dovecot:${MAILU_VERSION:-{{ version }}} image: ${DOCKER_ORG:-ghcr.io/mailu}/${DOCKER_PREFIX:-}dovecot:${MAILU_VERSION:-{{ version }}}
restart: always restart: always
env_file: {{ env }} env_file: {{ env }}
logging:
driver: journald
options:
tag: mailu-imap
volumes: volumes:
- "{{ root }}/mail:/mail" - "{{ root }}/mail:/mail"
- "{{ root }}/overrides/dovecot:/overrides:ro" - "{{ root }}/overrides/dovecot:/overrides:ro"
@@ -96,6 +110,10 @@ services:
image: ${DOCKER_ORG:-ghcr.io/mailu}/${DOCKER_PREFIX:-}postfix:${MAILU_VERSION:-{{ version }}} image: ${DOCKER_ORG:-ghcr.io/mailu}/${DOCKER_PREFIX:-}postfix:${MAILU_VERSION:-{{ version }}}
restart: always restart: always
env_file: {{ env }} env_file: {{ env }}
logging:
driver: journald
options:
tag: mailu-smtp
volumes: volumes:
- "{{ root }}/mailqueue:/queue" - "{{ root }}/mailqueue:/queue"
- "{{ root }}/overrides/postfix:/overrides:ro" - "{{ root }}/overrides/postfix:/overrides:ro"
@@ -127,6 +145,10 @@ services:
hostname: antispam hostname: antispam
restart: always restart: always
env_file: {{ env }} env_file: {{ env }}
logging:
driver: journald
options:
tag: mailu-antispam
{% if oletools_enabled %} {% if oletools_enabled %}
networks: networks:
- default - default

View File

@@ -158,12 +158,6 @@ DOMAIN_REGISTRATION=true
# Advanced settings # Advanced settings
################################### ###################################
# Log driver for front service. Possible values:
# json-file (default)
# journald (On systemd platforms, useful for Fail2Ban integration)
# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker compose log` for front!)
# LOG_DRIVER={{ log_driver or 'json-file' }}
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME={{ compose_project_name or 'mailu' }} COMPOSE_PROJECT_NAME={{ compose_project_name or 'mailu' }}

View File

@@ -47,7 +47,7 @@ Or in plain english: if receivers start to classify your mail as spam, this post
<label>Authentication rate limit per user</label> <label>Authentication rate limit per user</label>
<!-- Validates number input only --> <!-- Validates number input only -->
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_user" <p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_user"
value="100" required > / day value="50" required > / day
</p> </p>
</div> </div>

View File

@@ -0,0 +1 @@
Change the behaviour of AUTH_RATELIMIT_USER and only account for distinct attempts. Same username and same password is now a only accounted once per period.

View File

@@ -0,0 +1 @@
Ensure we ask for the existing password before processing a password change request.

View File

@@ -0,0 +1,2 @@
Remove LOG_DRIVER which never worked and replace it with journald by default
Fix a bug where front may get attached to networks that don't exist