Enforce SPF
This commit is contained in:
parent
149a06dd68
commit
001079bdc5
|
@ -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")
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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 ###
|
|
@ -38,4 +38,5 @@ facebook-sdk
|
|||
google-api-python-client
|
||||
google-auth-httplib2
|
||||
python-gnupg
|
||||
webauthn
|
||||
webauthn
|
||||
pyspf
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue