1
0
mirror of https://github.com/Mailu/Mailu.git synced 2025-03-03 14:52:36 +02:00

Merge branch 'master' into ratelimits

This commit is contained in:
Florent Daigniere 2021-10-12 14:47:00 +02:00 committed by GitHub
commit 7277e0b4e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 78 additions and 55 deletions

View File

@ -66,5 +66,12 @@ $('document').ready(function() {
// init clipboard.js // init clipboard.js
new ClipboardJS('.btn-clip'); new ClipboardJS('.btn-clip');
// disable login if not possible
var l = $('#login_needs_https');
if (l.length && window.location.protocol != 'https:') {
l.removeClass("d-none");
$('form :input').prop('disabled', true);
}
}); });

View File

@ -30,6 +30,7 @@ def create_app_from_config(config):
app.device_cookie_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('DEVICE_COOKIE_KEY', 'utf-8'), 'sha256').digest() 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()
app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest()
# Initialize list of translations # Initialize list of translations
config.translations = { config.translations = {

View File

@ -52,6 +52,7 @@ DEFAULT_CONFIG = {
'DKIM_PATH': '/dkim/{domain}.{selector}.key', 'DKIM_PATH': '/dkim/{domain}.{selector}.key',
'DEFAULT_QUOTA': 1000000000, 'DEFAULT_QUOTA': 1000000000,
'MESSAGE_RATELIMIT': '200/day', 'MESSAGE_RATELIMIT': '200/day',
'RECIPIENT_DELIMITER': '',
# Web settings # Web settings
'SITENAME': 'Mailu', 'SITENAME': 'Mailu',
'WEBSITE': 'https://mailu.io', 'WEBSITE': 'https://mailu.io',

View File

@ -5,6 +5,7 @@ import re
import urllib import urllib
import ipaddress import ipaddress
import socket import socket
import sqlalchemy.exc
import tenacity import tenacity
SUPPORTED_AUTH_METHODS = ["none", "plain"] SUPPORTED_AUTH_METHODS = ["none", "plain"]
@ -77,7 +78,7 @@ def handle_authentication(headers):
# Authenticated user # Authenticated user
elif method == "plain": elif method == "plain":
is_valid_user = False is_valid_user = False
if 'Auth-Port' in headers and int(urllib.parse.unquote(headers["Auth-Port"])) == 25: if 'Auth-Port' in headers and urllib.parse.unquote(headers["Auth-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",
@ -92,21 +93,26 @@ def handle_authentication(headers):
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")
ip = urllib.parse.unquote(headers["Client-Ip"])
except: except:
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) try:
is_valid_user = True user = models.User.query.get(user_email)
ip = urllib.parse.unquote(headers["Client-Ip"]) is_valid_user = True
if check_credentials(user, password, ip, protocol): except sqlalchemy.exc.StatementError as exc:
server, port = get_server(headers["Auth-Protocol"], True) exc = str(exc).split('\n', 1)[0]
return { app.logger.warn(f'Invalid user {user_email!r}: {exc}')
"Auth-Status": "OK", else:
"Auth-Server": server, if check_credentials(user, password, ip, protocol):
"Auth-User": user_email, server, port = get_server(headers["Auth-Protocol"], True)
"Auth-User-Exists": is_valid_user, return {
"Auth-Port": port "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") status, code = get_status(protocol, "authentication")
return { return {
"Auth-Status": status, "Auth-Status": status,

View File

@ -108,7 +108,7 @@ def postfix_recipient_map(recipient):
This is meant for bounces to go back to the original sender. This is meant for bounces to go back to the original sender.
""" """
srs = srslib.SRS(flask.current_app.config["SECRET_KEY"]) srs = srslib.SRS(flask.current_app.srs_key)
if srslib.SRS.is_srs_address(recipient): if srslib.SRS.is_srs_address(recipient):
try: try:
return flask.jsonify(srs.reverse(recipient)) return flask.jsonify(srs.reverse(recipient))
@ -123,7 +123,7 @@ def postfix_sender_map(sender):
This is for bounces to come back the reverse path properly. This is for bounces to come back the reverse path properly.
""" """
srs = srslib.SRS(flask.current_app.config["SECRET_KEY"]) srs = srslib.SRS(flask.current_app.srs_key)
domain = flask.current_app.config["DOMAIN"] domain = flask.current_app.config["DOMAIN"]
try: try:
localpart, domain_name = models.Email.resolve_domain(sender) localpart, domain_name = models.Email.resolve_domain(sender)
@ -140,6 +140,7 @@ def postfix_sender_login(sender):
localpart, domain_name = models.Email.resolve_domain(sender) localpart, domain_name = models.Email.resolve_domain(sender)
if localpart is None: if localpart is None:
return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404) return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404)
localpart = localpart[:next((i for i, ch in enumerate(localpart) if ch in flask.current_app.config.get('RECIPIENT_DELIMITER')), None)]
destination = models.Email.resolve_destination(localpart, domain_name, True) destination = models.Email.resolve_destination(localpart, domain_name, True)
destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders] destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders]
return flask.jsonify(",".join(destination)) if destination else flask.abort(404) return flask.jsonify(",".join(destination)) if destination else flask.abort(404)

View File

@ -57,6 +57,8 @@ class IdnaEmail(db.TypeDecorator):
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
""" encode unicode domain part of email address to punycode """ """ encode unicode domain part of email address to punycode """
if not '@' in value:
raise ValueError('invalid email address (no "@")')
localpart, domain_name = value.lower().rsplit('@', 1) localpart, domain_name = value.lower().rsplit('@', 1)
if '@' in localpart: if '@' in localpart:
raise ValueError('email local part must not contain "@"') raise ValueError('email local part must not contain "@"')

View File

@ -7,3 +7,12 @@
{%- block subtitle %} {%- block subtitle %}
{% trans %}to access the administration tools{% endtrans %} {% trans %}to access the administration tools{% endtrans %}
{%- endblock %} {%- endblock %}
{%- block content %}
{% if config["SESSION_COOKIE_SECURE"] %}
<div id="login_needs_https" class="alert alert-danger d-none" role="alert">
{% trans %}The login form has been disabled as <b>SESSION_COOKIE_SECURE</b> is on but you are accessing Mailu over HTTP.{% endtrans %}
</div>
{% endif %}
{{ super() }}
{%- endblock %}

View File

@ -60,7 +60,7 @@ def has_dane_record(domain, timeout=10):
# we will receive this non-specific exception. The safe behaviour is to # we will receive this non-specific exception. The safe behaviour is to
# accept to defer the email. # 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/?') 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 app.config['DEFER_ON_TLS_ERROR'] return flask.current_app.config['DEFER_ON_TLS_ERROR']
except dns.exception.Timeout: except dns.exception.Timeout:
flask.current_app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).') flask.current_app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).')
except dns.resolver.NXDOMAIN: except dns.resolver.NXDOMAIN:

View File

@ -1,17 +1,8 @@
# This configuration was copied from Mailinabox. The original version is available at: # This configuration was copied from Mailinabox. The original version is available at:
# https://raw.githubusercontent.com/mail-in-a-box/mailinabox/master/conf/postfix_outgoing_mail_header_filters # https://raw.githubusercontent.com/mail-in-a-box/mailinabox/master/conf/postfix_outgoing_mail_header_filters
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header # Remove typically private information.
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is /^\s*(Received|User-Agent|X-(Enigmail|Mailer|Originating-IP|Pgp-Agent)):/ IGNORE
# where the user's home IP address would be.
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user ({{OUTCLEAN}} [{{OUTCLEAN_ADDRESS}}])$1
# Remove other typically private information.
/^\s*User-Agent:/ IGNORE
/^\s*X-Enigmail:/ IGNORE
/^\s*X-Mailer:/ IGNORE
/^\s*X-Originating-IP:/ IGNORE
/^\s*X-Pgp-Agent:/ IGNORE
# The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)). # The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)).
/^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1 /^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1

View File

@ -46,15 +46,6 @@ os.environ["FRONT_ADDRESS"] = system.get_host_address_from_environment("FRONT",
os.environ["ADMIN_ADDRESS"] = system.get_host_address_from_environment("ADMIN", "admin") os.environ["ADMIN_ADDRESS"] = system.get_host_address_from_environment("ADMIN", "admin")
os.environ["ANTISPAM_MILTER_ADDRESS"] = system.get_host_address_from_environment("ANTISPAM_MILTER", "antispam:11332") os.environ["ANTISPAM_MILTER_ADDRESS"] = system.get_host_address_from_environment("ANTISPAM_MILTER", "antispam:11332")
os.environ["LMTP_ADDRESS"] = system.get_host_address_from_environment("LMTP", "imap:2525") os.environ["LMTP_ADDRESS"] = system.get_host_address_from_environment("LMTP", "imap:2525")
os.environ["OUTCLEAN"] = os.environ["HOSTNAMES"].split(",")[0]
try:
_to_lookup = os.environ["OUTCLEAN"]
# Ensure we lookup a FQDN: @see #1884
if not _to_lookup.endswith('.'):
_to_lookup += '.'
os.environ["OUTCLEAN_ADDRESS"] = system.resolve_hostname(_to_lookup)
except:
os.environ["OUTCLEAN_ADDRESS"] = "10.10.10.10"
for postfix_file in glob.glob("/conf/*.cf"): for postfix_file in glob.glob("/conf/*.cf"):
conf.jinja(postfix_file, os.environ, os.path.join("/etc/postfix", os.path.basename(postfix_file))) conf.jinja(postfix_file, os.environ, os.path.join("/etc/postfix", os.path.basename(postfix_file)))

View File

@ -100,9 +100,10 @@ go and fetch new email if available. Do not use too short delays if you do not
want to be blacklisted by external services, but not too long delays if you want to be blacklisted by external services, but not too long delays if you
want to receive your email in time. want to receive your email in time.
The ``RECIPIENT_DELIMITER`` is a character used to delimit localpart from a The ``RECIPIENT_DELIMITER`` is a list of characters used to delimit localpart
custom address part. For instance, if set to ``+``, users can use addresses from a custom address part. For instance, if set to ``+-``, users can use
like ``localpart+custom@domain.tld`` to deliver mail to ``localpart@domain.tld``. addresses like ``localpart+custom@example.com`` or ``localpart-custom@example.com``
to deliver mail to ``localpart@example.com``.
This is useful to provide external parties with different email addresses and This is useful to provide external parties with different email addresses and
later classify incoming mail based on the custom part. later classify incoming mail based on the custom part.

View File

@ -1,19 +1,20 @@
server: server:
verbosity: 1 verbosity: 1
interface: 0.0.0.0 interface: 0.0.0.0
interface: ::0 {{ 'interface: ::0' if SUBNET6 }}
logfile: "" logfile: ""
do-ip4: yes do-ip4: yes
do-ip6: yes do-ip6: {{ 'yes' if SUBNET6 else 'no' }}
do-udp: yes do-udp: yes
do-tcp: yes do-tcp: yes
do-daemonize: no do-daemonize: no
access-control: {{ SUBNET }} allow access-control: {{ SUBNET }} allow
{{ 'access-control: {{ SUBNET6 }} allow' if SUBNET6 }}
directory: "/etc/unbound" directory: "/etc/unbound"
username: unbound username: unbound
auto-trust-anchor-file: trusted-key.key auto-trust-anchor-file: trusted-key.key
root-hints: "/etc/unbound/root.hints" root-hints: "/etc/unbound/root.hints"
hide-identity: yes hide-identity: yes
hide-version: yes hide-version: yes
max-udp-size: 4096 cache-min-ttl: 300
msg-buffer-size: 65552

View File

@ -0,0 +1 @@
Fixed roundcube sso login not working.

View File

@ -0,0 +1,3 @@
Make unbound work with ipv6
Add a cache-min-ttl of 5minutes
Enable qname minimisation (privacy)

View File

@ -0,0 +1 @@
Disable the login page if SESSION_COOKIE_SECURE is incompatible with how Mailu is accessed as this seems to be a common misconfiguration.

View File

@ -0,0 +1 @@
Derive a new subkey (from SECRET_KEY) for SRS

View File

@ -0,0 +1 @@
allow sending emails as user+detail@domain.tld

View File

@ -0,0 +1 @@
Remove the Received header with PRIMARY_HOSTNAME [PUBLIC_IP]

View File

@ -11,7 +11,8 @@ FROM build_${QEMU}
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
python3 curl python3-pip git python3-multidict \ python3 curl python3-pip git python3-multidict \
&& rm -rf /var/lib/apt/lists \ && rm -rf /var/lib/apt/lists \
&& echo "ServerSignature Off" >> /etc/apache2/apache2.conf && echo "ServerSignature Off\nServerName roundcube" >> /etc/apache2/apache2.conf \
&& sed -i 's,CustomLog.*combined$,\0 "'"expr=!(%{HTTP_USER_AGENT}=='health'\&\&(-R '127.0.0.1/8' || -R '::1'))"'",' /etc/apache2/sites-available/000-default.conf
# Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube # Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube
RUN pip3 install socrate RUN pip3 install socrate
@ -33,11 +34,15 @@ RUN apt-get update && apt-get install -y \
&& mv roundcubemail-* html \ && mv roundcubemail-* html \
&& mv carddav html/plugins/ \ && mv carddav html/plugins/ \
&& cd html \ && cd html \
&& rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer \ && rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer composer.* \
&& sed -i 's,mod_php5.c,mod_php7.c,g' .htaccess \ && sed -i 's,mod_php5.c,mod_php7.c,g' .htaccess \
&& sed -i 's,^php_value.*post_max_size,#&,g' .htaccess \ && sed -i 's,^php_value.*post_max_size,#&,g' .htaccess \
&& sed -i 's,^php_value.*upload_max_filesize,#&,g' .htaccess \ && sed -i 's,^php_value.*upload_max_filesize,#&,g' .htaccess \
&& chown -R www-data: logs temp \ && ln -sf index.php /var/www/html/sso.php \
&& ln -sf /dev/stderr /var/www/html/logs/errors.log \
&& chown -R root:root . \
&& chown www-data:www-data logs temp \
&& chmod -R a+rX . \
&& rm -rf /var/lib/apt/lists \ && rm -rf /var/lib/apt/lists \
&& a2enmod rewrite deflate expires headers && a2enmod rewrite deflate expires headers
@ -51,4 +56,4 @@ VOLUME ["/data"]
CMD /start.py CMD /start.py
HEALTHCHECK CMD curl -f -L http://localhost/ || exit 1 HEALTHCHECK CMD curl -f -L -H 'User-Agent: health' http://localhost/ || exit 1

View File

@ -52,6 +52,12 @@ class mailu extends rcube_plugin
} }
function login_failed($args) function login_failed($args)
{ {
$ua = $_SERVER['HTTP_USER_AGENT'];
$ra = $_SERVER['REMOTE_ADDR'];
if ($ua == 'health' and ($ra == '127.0.0.1' or $ra == '::1')) {
echo "OK";
exit;
}
header('Location: sso.php'); header('Location: sso.php');
exit(); exit();
} }

View File

@ -34,11 +34,7 @@ else:
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini") conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini")
# Create dirs, setup permissions # Create dirs, setup permissions
os.system("mkdir -p /data/gpg /var/www/html/logs") os.system("mkdir -p /data/gpg")
os.system("touch /var/www/html/logs/errors.log")
os.system("chown -R www-data:www-data /var/www/html/logs")
os.system("chmod -R a+rX /var/www/html/")
os.system("ln -sf /var/www/html/index.php /var/www/html/sso.php")
try: try:
print("Initializing database") print("Initializing database")
@ -61,8 +57,5 @@ except subprocess.CalledProcessError as e:
# Setup database permissions # Setup database permissions
os.system("chown -R www-data:www-data /data") os.system("chown -R www-data:www-data /data")
# Tail roundcube logs
subprocess.Popen(["tail", "-f", "-n", "0", "/var/www/html/logs/errors.log"])
# Run apache # Run apache
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"]) os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])