diff --git a/README.md b/README.md index c4354b28..4c19ad78 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Main features include: - **Web access**, multiple Webmails and administration interface - **User features**, aliases, auto-reply, auto-forward, fetched accounts - **Admin features**, global admins, announcements, per-domain delegation, quotas -- **Security**, enforced TLS, Letsencrypt!, outgoing DKIM, anti-virus scanner +- **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner - **Antispam**, auto-learn, greylisting, DMARC and SPF - **Freedom**, all FOSS components, no tracker included diff --git a/core/admin/Dockerfile b/core/admin/Dockerfile index 3ac4fcd4..083c0f39 100644 --- a/core/admin/Dockerfile +++ b/core/admin/Dockerfile @@ -1,5 +1,5 @@ # First stage to build assets -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 ARG ARCH="" FROM ${ARCH}node:16 as assets @@ -33,8 +33,8 @@ WORKDIR /app COPY requirements-prod.txt requirements.txt RUN set -eu \ - && apk add --no-cache openssl curl postgresql-libs mariadb-connector-c \ - && apk add --no-cache --virtual build-dep openssl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev cargo \ + && apk add --no-cache libressl curl postgresql-libs mariadb-connector-c \ + && apk add --no-cache --virtual build-dep libressl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev cargo \ && pip3 install -r requirements.txt \ && apk del --no-cache build-dep diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 196b0197..4401888a 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -35,6 +35,7 @@ DEFAULT_CONFIG = { 'WILDCARD_SENDERS': '', 'TLS_FLAVOR': 'cert', 'INBOUND_TLS_ENFORCE': False, + 'DEFER_ON_TLS_ERROR': True, 'AUTH_RATELIMIT': '1000/minute;10000/hour', 'AUTH_RATELIMIT_SUBNET': False, 'DISABLE_STATISTICS': False, diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 5e60cd0c..167341e2 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -71,16 +71,6 @@ def handle_authentication(headers): } # Authenticated user elif method == "plain": - server, port = get_server(headers["Auth-Protocol"], True) - # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should - # be ASCII and are generally considered ISO8859-1. However when passing - # the password, nginx does not transcode the input UTF string, thus - # we need to manually decode. - raw_user_email = urllib.parse.unquote(headers["Auth-User"]) - user_email = raw_user_email.encode("iso8859-1").decode("utf8") - raw_password = urllib.parse.unquote(headers["Auth-Pass"]) - password = raw_password.encode("iso8859-1").decode("utf8") - ip = urllib.parse.unquote(headers["Client-Ip"]) service_port = int(urllib.parse.unquote(headers["Auth-Port"])) if service_port == 25: return { @@ -88,20 +78,33 @@ def handle_authentication(headers): "Auth-Error-Code": "502 5.5.1", "Auth-Wait": 0 } - user = models.User.query.get(user_email) - if check_credentials(user, password, ip, protocol): - return { - "Auth-Status": "OK", - "Auth-Server": server, - "Auth-Port": port - } + # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should + # be ASCII and are generally considered ISO8859-1. However when passing + # the password, nginx does not transcode the input UTF string, thus + # we need to manually decode. + raw_user_email = urllib.parse.unquote(headers["Auth-User"]) + raw_password = urllib.parse.unquote(headers["Auth-Pass"]) + try: + user_email = raw_user_email.encode("iso8859-1").decode("utf8") + password = raw_password.encode("iso8859-1").decode("utf8") + except: + app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}') else: - status, code = get_status(protocol, "authentication") - return { - "Auth-Status": status, - "Auth-Error-Code": code, - "Auth-Wait": 0 - } + user = models.User.query.get(user_email) + 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-Port": port + } + status, code = get_status(protocol, "authentication") + return { + "Auth-Status": status, + "Auth-Error-Code": code, + "Auth-Wait": 0 + } # Unexpected return {} diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index 2e7d0b9b..330fed5b 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -7,6 +7,9 @@ import idna import re import srslib +@internal.route("/postfix/dane/") +def postfix_dane_map(domain_name): + return flask.jsonify('dane-only') if utils.has_dane_record(domain_name) else flask.abort(404) @internal.route("/postfix/domain/") def postfix_mailbox_domain(domain_name): diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index ebb55e3b..34f52d8c 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -6,6 +6,9 @@ try: except ImportError: import pickle +import dns +import dns.resolver + import hmac import secrets import time @@ -25,7 +28,6 @@ from itsdangerous.encoding import want_bytes from werkzeug.datastructures import CallbackDict from werkzeug.contrib import fixers - # Login configuration login = flask_login.LoginManager() login.login_view = "ui.login" @@ -37,6 +39,34 @@ def handle_needs_login(): flask.url_for('ui.login', next=flask.request.endpoint) ) +# DNS stub configured to do DNSSEC enabled queries +resolver = dns.resolver.Resolver() +resolver.use_edns(0, 0, 1232) +resolver.flags = dns.flags.AD | dns.flags.RD + +def has_dane_record(domain, timeout=10): + try: + result = resolver.query(f'_25._tcp.{domain}', dns.rdatatype.TLSA,dns.rdataclass.IN, lifetime=timeout) + if result.response.flags & dns.flags.AD: + for record in result: + if isinstance(record, dns.rdtypes.ANY.TLSA.TLSA): + record.validate() + if record.usage in [2,3] and record.selector in [0,1] and record.mtype in [0,1,2]: + return True + except dns.resolver.NoNameservers: + # 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 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).') + 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}') + pass + # Rate limiter limiter = limiter.LimitWraperFactory() diff --git a/core/dovecot/Dockerfile b/core/dovecot/Dockerfile index 22145bde..49fcb866 100644 --- a/core/dovecot/Dockerfile +++ b/core/dovecot/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO as builder WORKDIR /tmp RUN apk add git build-base automake autoconf libtool dovecot-dev xapian-core-dev icu-dev diff --git a/core/nginx/Dockerfile b/core/nginx/Dockerfile index 3b10b8b8..aeb11666 100644 --- a/core/nginx/Dockerfile +++ b/core/nginx/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf index 4c31674c..bfd664de 100644 --- a/core/nginx/conf/nginx.conf +++ b/core/nginx/conf/nginx.conf @@ -18,7 +18,7 @@ http { keepalive_timeout 65; server_tokens off; absolute_redirect off; - resolver {{ RESOLVER }} valid=30s; + resolver {{ RESOLVER }} ipv6=off valid=30s; {% if REAL_IP_HEADER %} real_ip_header {{ REAL_IP_HEADER }}; @@ -246,7 +246,7 @@ mail { server_name {{ HOSTNAMES.split(",")[0] }}; auth_http http://127.0.0.1:8000/auth/email; proxy_pass_error_message on; - resolver {{ RESOLVER }} valid=30s; + resolver {{ RESOLVER }} ipv6=off valid=30s; {% if TLS and not TLS_ERROR %} include /etc/nginx/tls.conf; diff --git a/core/none/Dockerfile b/core/none/Dockerfile index 51b8d1c5..bae5e8a3 100644 --- a/core/none/Dockerfile +++ b/core/none/Dockerfile @@ -1,6 +1,6 @@ # This is an idle image to dynamically replace any component if disabled. -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO CMD sleep 1000000d diff --git a/core/postfix/Dockerfile b/core/postfix/Dockerfile index 062155c1..e93a584c 100644 --- a/core/postfix/Dockerfile +++ b/core/postfix/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ @@ -12,10 +12,15 @@ RUN pip3 install socrate==0.2.0 RUN pip3 install "podop>0.2.5" # Image specific layers under this line +RUN apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev +RUN pip3 install --no-binary :all: postfix-mta-sts-resolver==1.0.1 +RUN apk del .build-deps gcc musl-dev python3-dev + RUN apk add --no-cache postfix postfix-pcre cyrus-sasl-login COPY conf /conf COPY start.py /start.py +COPY mta-sts-daemon.yml /etc/ EXPOSE 25/tcp 10025/tcp VOLUME ["/queue"] diff --git a/core/postfix/conf/main.cf b/core/postfix/conf/main.cf index 99c5179a..6152388c 100644 --- a/core/postfix/conf/main.cf +++ b/core/postfix/conf/main.cf @@ -58,13 +58,17 @@ tls_ssl_options = NO_COMPRESSION, NO_TICKET # 2. not all will have and up-to-date TLS stack. smtp_tls_mandatory_protocols = !SSLv2, !SSLv3 smtp_tls_protocols =!SSLv2,!SSLv3 -smtp_tls_security_level = {{ OUTBOUND_TLS_LEVEL|default('may') }} -smtp_tls_policy_maps=lmdb:/etc/postfix/tls_policy.map +smtp_tls_security_level = {{ OUTBOUND_TLS_LEVEL|default('dane') }} +smtp_tls_dane_insecure_mx_policy = {% if DEFER_ON_TLS_ERROR == 'false' %}may{% else %}dane{% endif %} +smtp_tls_policy_maps=lmdb:/etc/postfix/tls_policy.map, ${podop}dane, socketmap:unix:/tmp/mta-sts.socket:postfix smtp_tls_CApath = /etc/ssl/certs smtp_tls_session_cache_database = lmdb:/dev/shm/postfix/smtp_scache smtpd_tls_session_cache_database = lmdb:/dev/shm/postfix/smtpd_scache smtp_host_lookup = dns smtp_dns_support_level = dnssec +delay_warning_time = 5m +smtp_tls_loglevel = 1 +notify_classes = resource, software, delay ############### # Virtual diff --git a/core/postfix/mta-sts-daemon.yml b/core/postfix/mta-sts-daemon.yml new file mode 100644 index 00000000..361bcbf9 --- /dev/null +++ b/core/postfix/mta-sts-daemon.yml @@ -0,0 +1,10 @@ +path: "/tmp/mta-sts.socket" +mode: 0600 +shutdown_timeout: 20 +cache: + type: internal + options: + cache_size: 10000 +default_zone: + strict_testing: {{ DEFER_ON_TLS_ERROR |default('true') }} + timeout: 4 diff --git a/core/postfix/start.py b/core/postfix/start.py index 799d42f5..3de83a63 100755 --- a/core/postfix/start.py +++ b/core/postfix/start.py @@ -19,9 +19,10 @@ def start_podop(): url = "http://" + os.environ["ADMIN_ADDRESS"] + "/internal/postfix/" # TODO: Remove verbosity setting from Podop? run_server(0, "postfix", "/tmp/podop.socket", [ - ("transport", "url", url + "transport/§"), - ("alias", "url", url + "alias/§"), - ("domain", "url", url + "domain/§"), + ("transport", "url", url + "transport/§"), + ("alias", "url", url + "alias/§"), + ("dane", "url", url + "dane/§"), + ("domain", "url", url + "domain/§"), ("mailbox", "url", url + "mailbox/§"), ("recipientmap", "url", url + "recipient/map/§"), ("sendermap", "url", url + "sender/map/§"), @@ -30,6 +31,12 @@ def start_podop(): ("senderrate", "url", url + "sender/rate/§") ]) +def start_mta_sts_daemon(): + os.chmod("/root/", 0o755) # read access to /root/.netrc required + os.setuid(getpwnam('postfix').pw_uid) + from postfix_mta_sts_resolver import daemon + daemon.main() + def is_valid_postconf_line(line): return not line.startswith("#") \ and not line == '' @@ -68,10 +75,12 @@ for map_file in glob.glob("/overrides/*.map"): os.system("postmap {}".format(destination)) os.remove(destination) -if not os.path.exists("/etc/postfix/tls_policy.map.db"): - with open("/etc/postfix/tls_policy.map", "w") as f: - for domain in ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'outlook.com', 'comcast.net', 'icloud.com', 'msn.com', 'hotmail.co.uk', 'live.com', 'yahoo.co.in', 'me.com', 'mail.ru', 'cox.net', 'yahoo.co.uk', 'verizon.net', 'ymail.com', 'hotmail.it', 'kw.com', 'yahoo.com.tw', 'mac.com', 'live.se', 'live.nl', 'yahoo.com.br', 'googlemail.com', 'libero.it', 'web.de', 'allstate.com', 'btinternet.com', 'online.no', 'yahoo.com.au', 'live.dk', 'earthlink.net', 'yahoo.fr', 'yahoo.it', 'gmx.de', 'hotmail.fr', 'shawinc.com', 'yahoo.de', 'moe.edu.sg', 'naver.com', 'bigpond.com', 'statefarm.com', 'remax.net', 'rocketmail.com', 'live.no', 'yahoo.ca', 'bigpond.net.au', 'hotmail.se', 'gmx.at', 'live.co.uk', 'mail.com', 'yahoo.in', 'yandex.ru', 'qq.com', 'charter.net', 'indeedemail.com', 'alice.it', 'hotmail.de', 'bluewin.ch', 'optonline.net', 'wp.pl', 'yahoo.es', 'hotmail.no', 'pindotmedia.com', 'orange.fr', 'live.it', 'yahoo.co.id', 'yahoo.no', 'hotmail.es', 'morganstanley.com', 'wellsfargo.com', 'wanadoo.fr', 'facebook.com', 'yahoo.se', 'fema.dhs.gov', 'rogers.com', 'yahoo.com.hk', 'live.com.au', 'nic.in', 'nab.com.au', 'ubs.com', 'shaw.ca', 'umich.edu', 'westpac.com.au', 'yahoo.com.mx', 'yahoo.com.sg', 'farmersagent.com', 'yahoo.dk', 'dhs.gov']: - f.write(f'{domain}\tsecure\n') +if os.path.exists("/overrides/mta-sts-daemon.yml"): + shutil.copyfile("/overrides/mta-sts-daemon.yml", "/etc/mta-sts-daemon.yml") +conf.jinja("/etc/mta-sts-daemon.yml", os.environ, "/etc/mta-sts-daemon.yml") + +if not os.path.exists("/etc/postfix/tls_policy.map.lmdb"): + open("/etc/postfix/tls_policy.map", "a").close() os.system("postmap /etc/postfix/tls_policy.map") if "RELAYUSER" in os.environ: @@ -81,6 +90,7 @@ if "RELAYUSER" in os.environ: # Run Podop and Postfix multiprocessing.Process(target=start_podop).start() +multiprocessing.Process(target=start_mta_sts_daemon).start() os.system("/usr/libexec/postfix/post-install meta_directory=/etc/postfix create-missing") # Before starting postfix, we need to check permissions on /queue # in the event that postfix,postdrop id have changed diff --git a/core/rspamd/Dockerfile b/core/rspamd/Dockerfile index 6706ef14..0b3b94f7 100644 --- a/core/rspamd/Dockerfile +++ b/core/rspamd/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ diff --git a/docs/configuration.rst b/docs/configuration.rst index 9da0a14c..5f17b57e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -72,8 +72,12 @@ mail in following format: ``[HOST]:PORT``. ``RELAYUSER`` and ``RELAYPASSWORD`` can be used when authentication is needed. By default postfix uses "opportunistic TLS" for outbound mail. This can be changed -by setting ``OUTBOUND_TLS_LEVEL`` to ``encrypt`` or ``secure``. This setting is highly recommended -if you are using a relayhost that supports TLS. +by setting ``OUTBOUND_TLS_LEVEL`` to ``encrypt`` or ``secure``. This setting is +highly recommended if you are using a relayhost that supports TLS but discouraged +otherwise. ``DEFER_ON_TLS_ERROR`` (default: True) controls whether incomplete +policies (DANE without DNSSEC or "testing" MTA-STS policies) will be taken into +account and whether emails will be defered if the additional checks enforced by +those policies fail. Similarily by default nginx uses "opportunistic TLS" for inbound mail. This can be changed by setting ``INBOUND_TLS_ENFORCE`` to ``True``. Please note that this is forbidden for diff --git a/docs/faq.rst b/docs/faq.rst index a2c6bd33..01557237 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -369,6 +369,31 @@ How do I use webdav (radicale)? .. _`575`: https://github.com/Mailu/Mailu/issues/575 .. _`1591`: https://github.com/Mailu/Mailu/issues/1591 +How do I setup a MTA-STS policy? +```````````````````````````````` + +Mailu can serve an `MTA-STS policy`_; To configure it you will need to: + +1. add ``mta-sts.example.com`` to the ``HOSTNAMES`` configuration variable (and ensure that a valid SSL certificate is available for it; this may mean restarting your smtp container) + +2. configure an override with the policy itself; for example, your ``overrides/nginx/mta-sts.conf`` could read: + +.. code-block:: bash + + location ^~ /.well-known/mta-sts.txt { + return 200 "version: STSv1 + mode: enforce + max_age: 1296000 + mx: mailu.example.com\r\n"; + } + +3. setup the appropriate DNS/CNAME record (``mta-sts.example.com`` -> ``mailu.example.com``) and DNS/TXT record (``_mta-sts.example.com`` -> ``v=STSv1; id=1``) paying attention to the ``TTL`` as this is used by MTA-STS. + +*issue reference:* `1798`_. + +.. _`1798`: https://github.com/Mailu/Mailu/issues/1798 +.. _`MTA-STS policy`: https://datatracker.ietf.org/doc/html/rfc8461 + Technical issues ---------------- @@ -397,6 +422,22 @@ Any mail related connection is proxied by nginx. Therefore the SMTP Banner is al .. _`1368`: https://github.com/Mailu/Mailu/issues/1368 +My emails are getting defered, what can I do? +````````````````````````````````````````````` + +Emails are asynchronous and it's not abnormal for them to be defered sometimes. That being said, Mailu enforces secure connections where possible using DANE and MTA-STS, both of which have the potential to delay indefinitely delivery if something is misconfigured. + +If delivery to a specific domain fails because their DANE records are invalid or their TLS configuration inadequate (expired certificate, ...), you can assist delivery by downgrading the security level for that domain by creating an override at ``overrides/postfix/tls_policy.map`` as follow: + +.. code-block:: bash + + domain.example.com may + domain.example.org encrypt + +The syntax and options are as described in `postfix's documentation`_. Re-creating the smtp container will be required for changes to take effect. + +.. _`postfix's documentation`: http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps + 403 - Access Denied Errors --------------------------- diff --git a/optional/clamav/Dockerfile b/optional/clamav/Dockerfile index 20cebcdc..efad01ad 100644 --- a/optional/clamav/Dockerfile +++ b/optional/clamav/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ diff --git a/optional/fetchmail/Dockerfile b/optional/fetchmail/Dockerfile index 506e409a..d3397a22 100644 --- a/optional/fetchmail/Dockerfile +++ b/optional/fetchmail/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images diff --git a/optional/postgresql/Dockerfile b/optional/postgresql/Dockerfile index 0f5034da..ab197b62 100644 --- a/optional/postgresql/Dockerfile +++ b/optional/postgresql/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ diff --git a/optional/radicale/Dockerfile b/optional/radicale/Dockerfile index 13761164..21c1d437 100644 --- a/optional/radicale/Dockerfile +++ b/optional/radicale/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images diff --git a/optional/unbound/Dockerfile b/optional/unbound/Dockerfile index abb45420..da979496 100644 --- a/optional/unbound/Dockerfile +++ b/optional/unbound/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.12 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO # python3 shared with most images RUN apk add --no-cache \ diff --git a/setup/Dockerfile b/setup/Dockerfile index 5775ab6b..e0f685ee 100644 --- a/setup/Dockerfile +++ b/setup/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 FROM $DISTRO RUN mkdir -p /app diff --git a/towncrier/newsfragments/1798.feature b/towncrier/newsfragments/1798.feature new file mode 100644 index 00000000..125b1767 --- /dev/null +++ b/towncrier/newsfragments/1798.feature @@ -0,0 +1 @@ +Implement MTA-STS and DANE validation. Introduce DEFER_ON_TLS_ERROR (default: True) to harden or loosen the policy enforcement.