Enforce SPF

This commit is contained in:
Sibren Vasse 2020-05-07 13:28:04 +02:00
parent 149a06dd68
commit 001079bdc5
9 changed files with 128 additions and 10 deletions

View File

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

View File

@ -64,6 +64,35 @@
</div>
<!-- END Change email -->
{% if spf_available %}
<div class="card">
<form method="post">
<input type="hidden" name="form-name" value="force-spf">
<div class="card-body">
<div class="card-title">
Enforce SPF
<div class="small-text">
Block emails to reverse alias if sender is not validated by SPF,
even when SPF is configured as soft-fail.
</div>
</div>
<label class="custom-switch cursor mt-2 pl-0"
data-toggle="tooltip"
{% if mailbox.force_spf %}
title="Disable SPF enforcement"
{% else %}
title="Enable SPF enforcement"
{% endif %}
>
<input type="checkbox" name="spf-status" class="custom-switch-input"
{{ "checked" if mailbox.force_spf else "" }}>
<span class="custom-switch-indicator"></span>
</label>
</div>
</form>
</div>
{% endif %}
<div class="card">
<form method="post">
@ -109,4 +138,11 @@
</div>
{% endblock %}
{% block script %}
<script>
$(".custom-switch-input").change(function (e) {
$(this).closest("form").submit();
});
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,4 +38,5 @@ facebook-sdk
google-api-python-client
google-auth-httplib2
python-gnupg
webauthn
webauthn
pyspf

View File

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