Merge branch 'master' into ratelimits

master
Florent Daigniere 3 years ago committed by GitHub
commit 7277e0b4e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

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

@ -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 "@"')

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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();
} }

@ -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"])

Loading…
Cancel
Save