diff --git a/app/config.py b/app/config.py index f8baba98..b4ca3b8d 100644 --- a/app/config.py +++ b/app/config.py @@ -62,6 +62,8 @@ except Exception: # maximum number of directory a premium user can create MAX_NB_DIRECTORY = 50 +ENFORCE_SPF = "ENFORCE_SPF" in os.environ + # allow to override postfix server locally POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1") diff --git a/app/dashboard/templates/dashboard/mailbox_detail.html b/app/dashboard/templates/dashboard/mailbox_detail.html index 9782d5a0..d2aec68c 100644 --- a/app/dashboard/templates/dashboard/mailbox_detail.html +++ b/app/dashboard/templates/dashboard/mailbox_detail.html @@ -64,6 +64,35 @@ + {% if spf_available %} +
+
+ + +
+
+ Enforce SPF +
+ Block emails to reverse alias if sender is not validated by SPF, + even when SPF is configured as soft-fail. +
+
+ +
+
+
+ {% endif %}
@@ -109,4 +138,11 @@
{% endblock %} +{% block script %} + +{% endblock %} diff --git a/app/dashboard/views/mailbox_detail.py b/app/dashboard/views/mailbox_detail.py index 7bf9b29f..b28057f2 100644 --- a/app/dashboard/views/mailbox_detail.py +++ b/app/dashboard/views/mailbox_detail.py @@ -7,7 +7,7 @@ from itsdangerous import Signer from wtforms import validators from wtforms.fields.html5 import EmailField -from app.config import MAILBOX_SECRET +from app.config import ENFORCE_SPF, MAILBOX_SECRET from app.config import URL from app.dashboard.base import dashboard_bp from app.email_utils import can_be_used_as_personal_email @@ -100,6 +100,24 @@ def mailbox_detail_route(mailbox_id): return redirect( url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) ) + elif request.form.get("form-name") == "force-spf": + if not ENFORCE_SPF: + flash("SPF enforcement globally not enabled", "error") + return redirect(url_for("dashboard.index")) + + mailbox.force_spf = ( + True if request.form.get("spf-status") == "on" else False + ) + db.session.commit() + flash( + "SPF enforcement was " + "enabled" + if request.form.get("spf-status") + else "disabled" + " succesfully", + "success", + ) + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) elif request.form.get("form-name") == "pgp": if request.form.get("action") == "save": if not current_user.is_premium(): @@ -129,6 +147,7 @@ def mailbox_detail_route(mailbox_id): url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) ) + spf_available = ENFORCE_SPF return render_template("dashboard/mailbox_detail.html", **locals()) diff --git a/app/models.py b/app/models.py index c40b25e6..1c088a23 100644 --- a/app/models.py +++ b/app/models.py @@ -1116,6 +1116,7 @@ class Mailbox(db.Model, ModelMixin): user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False) email = db.Column(db.String(256), unique=True, nullable=False) verified = db.Column(db.Boolean, default=False, nullable=False) + force_spf = db.Column(db.Boolean, default=True, server_default="1", nullable=False) # used when user wants to update mailbox email new_email = db.Column(db.String(256), unique=True) diff --git a/email_handler.py b/email_handler.py index 89a38dae..918edd0f 100644 --- a/email_handler.py +++ b/email_handler.py @@ -31,8 +31,12 @@ It should contain the following info: """ import email +import re +import spf import time import uuid +from aiosmtpd.controller import Controller +from aiosmtpd.smtp import Envelope from email import encoders from email.message import Message from email.mime.application import MIMEApplication @@ -41,9 +45,6 @@ from email.utils import parseaddr, formataddr from io import BytesIO from smtplib import SMTP -from aiosmtpd.controller import Controller -from aiosmtpd.smtp import Envelope - from app import pgp_utils, s3 from app.alias_utils import try_auto_create from app.config import ( @@ -54,6 +55,7 @@ from app.config import ( POSTFIX_SUBMISSION_TLS, UNSUBSCRIBER, LOAD_PGP_EMAIL_HANDLER, + ENFORCE_SPF, ) from app.email_utils import ( send_email, @@ -79,6 +81,7 @@ from app.models import ( CustomDomain, User, RefusedEmail, + Mailbox, ) from app.utils import random_string from init_app import load_pgp_public_keys @@ -465,6 +468,28 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str handle_bounce(contact, alias, msg, user, mailbox_email) return False, "550 SL E6" + mailb: Mailbox = Mailbox.get_by(email=mailbox_email) + if ENFORCE_SPF and mailb.force_spf: + if msg["X-SimpleLogin-Client-IP"]: + r = spf.check2( + i=msg["X-SimpleLogin-Client-IP"], s=envelope.mail_from.lower(), h=None + ) + # TODO: Handle temperr case (e.g. dns timeout) + # only an absolute pass, or no SPF policy at all is 'valid' + if r[0] not in ["pass", "none"]: + LOG.d( + "SPF validation failed for %s (reason %s)", mailbox_email, r[0], + ) + return False, "550 SL E11" + else: + LOG.d( + "Could not find X-SimpleLogin-Client-IP header %s -> %s", + mailbox_email, + address, + ) + + delete_header(msg, "X-SimpleLogin-Client-IP") + # only mailbox can send email to the reply-email if envelope.mail_from.lower() != mailbox_email.lower(): LOG.warning( diff --git a/example.env b/example.env index 24e4147d..6083f2a2 100644 --- a/example.env +++ b/example.env @@ -18,6 +18,9 @@ URL=http://localhost:7777 # domain used to create alias EMAIL_DOMAIN=sl.local +# Allow SimpleLogin to enforce SPF by using the extra headers from postfix +ENFORCE_SPF=true + # other domains that can be used to create aliases, in addition to EMAIL_DOMAIN OTHER_ALIAS_DOMAINS=["domain1.com", "domain2.com"] diff --git a/migrations/versions/2020_050823_126c5af661b3_.py b/migrations/versions/2020_050823_126c5af661b3_.py new file mode 100644 index 00000000..a15ef17e --- /dev/null +++ b/migrations/versions/2020_050823_126c5af661b3_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 126c5af661b3 +Revises: 026e7a782ed6 +Create Date: 2020-05-08 23:01:13.644821 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '126c5af661b3' +down_revision = '026e7a782ed6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mailbox', sa.Column('force_spf', sa.Boolean(), server_default='1', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mailbox', 'force_spf') + # ### end Alembic commands ### diff --git a/requirements.in b/requirements.in index cbd7def9..5edd207b 100644 --- a/requirements.in +++ b/requirements.in @@ -38,4 +38,5 @@ facebook-sdk google-api-python-client google-auth-httplib2 python-gnupg -webauthn \ No newline at end of file +webauthn +pyspf diff --git a/requirements.txt b/requirements.txt index b627b206..cb477ac8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,6 @@ aiohttp==3.5.4 # via raven-aiohttp, yacron aiosmtpd==1.2 # via -r requirements.in aiosmtplib==1.0.6 # via yacron alembic==1.0.10 # via flask-migrate -appnope==0.1.0 # via ipython arrow==0.14.2 # via -r requirements.in asn1crypto==0.24.0 # via cryptography async-timeout==3.0.1 # via aiohttp @@ -21,13 +20,14 @@ blinker==1.4 # via -r requirements.in, flask-debugtoolbar boto3==1.9.167 # via -r requirements.in, watchtower botocore==1.12.167 # via boto3, s3transfer cachetools==4.0.0 # via google-auth +cbor2==5.1.0 # via webauthn certifi==2019.3.9 # via requests, sentry-sdk cffi==1.12.3 # via bcrypt, cryptography chardet==3.0.4 # via aiohttp, requests click==7.0 # via flask, pip-tools coloredlogs==10.0 # via -r requirements.in crontab==0.22.5 # via yacron -cryptography==2.7 # via jwcrypto, pyopenssl +cryptography==2.7 # via jwcrypto, pyopenssl, webauthn decorator==4.4.0 # via ipython, traitlets dkimpy==1.0.1 # via -r requirements.in dnspython==1.16.0 # via -r requirements.in, dkimpy @@ -43,6 +43,7 @@ flask-profiler==1.8.1 # via -r requirements.in flask-sqlalchemy==2.4.0 # via -r requirements.in, flask-migrate flask-wtf==0.14.2 # via -r requirements.in flask==1.0.3 # via -r requirements.in, flask-admin, flask-cors, flask-debugtoolbar, flask-httpauth, flask-login, flask-migrate, flask-profiler, flask-sqlalchemy, flask-wtf +future==0.18.2 # via webauthn google-api-python-client==1.7.11 # via -r requirements.in google-auth-httplib2==0.0.3 # via -r requirements.in, google-api-python-client google-auth==1.11.2 # via google-api-python-client, google-auth-httplib2 @@ -79,10 +80,10 @@ pyasn1==0.4.8 # via pyasn1-modules, rsa pycparser==2.19 # via cffi pycryptodome==3.9.4 # via -r requirements.in pygments==2.4.2 # via ipython -pyopenssl==19.0.0 # via -r requirements.in -webauthn==0.4.7 # via manually +pyopenssl==19.0.0 # via -r requirements.in, webauthn pyotp==2.3.0 # via -r requirements.in pyparsing==2.4.0 # via packaging +pyspf==2.0.14 # via -r requirements.in pytest==4.6.3 # via -r requirements.in python-dateutil==2.8.0 # via alembic, arrow, botocore, strictyaml python-dotenv==0.10.3 # via -r requirements.in @@ -97,7 +98,7 @@ ruamel.yaml==0.15.97 # via strictyaml s3transfer==0.2.1 # via boto3 sentry-sdk==0.14.1 # via -r requirements.in simplejson==3.17.0 # via flask-profiler -six==1.12.0 # via bcrypt, cryptography, flask-cors, google-api-python-client, google-auth, packaging, pip-tools, prompt-toolkit, pyopenssl, pytest, python-dateutil, sqlalchemy-utils, traitlets +six==1.12.0 # via bcrypt, cryptography, flask-cors, google-api-python-client, google-auth, packaging, pip-tools, prompt-toolkit, pyopenssl, pytest, python-dateutil, sqlalchemy-utils, traitlets, webauthn sqlalchemy-utils==0.36.1 # via -r requirements.in sqlalchemy==1.3.12 # via alembic, flask-sqlalchemy, sqlalchemy-utils strictyaml==1.0.2 # via yacron @@ -107,6 +108,7 @@ uritemplate==3.0.1 # via google-api-python-client urllib3==1.25.3 # via botocore, requests, sentry-sdk watchtower==0.6.0 # via -r requirements.in wcwidth==0.1.7 # via prompt-toolkit, pytest +webauthn==0.4.7 # via -r requirements.in werkzeug==0.15.4 # via flask, flask-debugtoolbar wtforms==2.2.1 # via -r requirements.in, flask-admin, flask-wtf yacron==0.9.0 # via -r requirements.in