Sibren Vasse 5 éve
szülő
commit
001079bdc5

+ 2 - 0
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")
 

+ 36 - 0
app/dashboard/templates/dashboard/mailbox_detail.html

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

+ 20 - 1
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())
 
 

+ 1 - 0
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)

+ 28 - 3
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(

+ 3 - 0
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"]
 

+ 29 - 0
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 ###

+ 2 - 1
requirements.in

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

+ 7 - 5
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