mirror of
https://github.com/simple-login/app.git
synced 2025-02-25 00:03:03 +08:00
Apply dmarc policy to the reply phase
This commit is contained in:
parent
9aeceb9119
commit
8ca1be0166
10 changed files with 182 additions and 16 deletions
|
@ -304,6 +304,9 @@ MAX_ALERT_24H = 4
|
|||
# When a reverse-alias receives emails from un unknown mailbox
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX = "reverse_alias_unknown_mailbox"
|
||||
|
||||
# When somebody is trying to spoof a reply
|
||||
ALERT_DMARC_FAILED_REPLY_PHASE = "dmarc_failed_reply_phase"
|
||||
|
||||
# When a forwarding email is bounced
|
||||
ALERT_BOUNCE_EMAIL = "bounce"
|
||||
|
||||
|
|
|
@ -73,6 +73,7 @@ from app.models import (
|
|||
DmarcCheckResult,
|
||||
SpamdResult,
|
||||
SPFCheckResult,
|
||||
Phase,
|
||||
)
|
||||
from app.utils import (
|
||||
random_string,
|
||||
|
@ -1443,7 +1444,9 @@ def save_email_for_debugging(msg: Message, file_name_prefix=None) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def get_spamd_result(msg: Message) -> Optional[SpamdResult]:
|
||||
def get_spamd_result(
|
||||
msg: Message, send_event: bool = True, phase: Phase = Phase.unknown
|
||||
) -> Optional[SpamdResult]:
|
||||
spam_result_header = msg.get_all(headers.SPAMD_RESULT)
|
||||
if not spam_result_header:
|
||||
newrelic.agent.record_custom_event("SpamdCheck", {"header": "missing"})
|
||||
|
@ -1455,7 +1458,7 @@ def get_spamd_result(msg: Message) -> Optional[SpamdResult]:
|
|||
if sep > -1:
|
||||
spam_entries[entry_pos] = spam_entries[entry_pos][:sep]
|
||||
|
||||
spamd_result = SpamdResult()
|
||||
spamd_result = SpamdResult(phase)
|
||||
|
||||
for header_value, dmarc_result in DmarcCheckResult.get_string_dict().items():
|
||||
if header_value in spam_entries:
|
||||
|
@ -1464,5 +1467,6 @@ def get_spamd_result(msg: Message) -> Optional[SpamdResult]:
|
|||
if header_value in spam_entries:
|
||||
spamd_result.set_spf_result(spf_result)
|
||||
|
||||
newrelic.agent.record_custom_event("SpamdCheck", spamd_result.event_data())
|
||||
if send_event:
|
||||
newrelic.agent.record_custom_event("SpamdCheck", spamd_result.event_data())
|
||||
return spamd_result
|
||||
|
|
|
@ -237,6 +237,12 @@ class AuditLogActionEnum(EnumE):
|
|||
extend_subscription = 7
|
||||
|
||||
|
||||
class Phase(EnumE):
|
||||
unknown = 0
|
||||
forward = 1
|
||||
reply = 2
|
||||
|
||||
|
||||
class DmarcCheckResult(EnumE):
|
||||
allow = 0
|
||||
soft_fail = 1
|
||||
|
@ -280,7 +286,8 @@ class SPFCheckResult(EnumE):
|
|||
|
||||
|
||||
class SpamdResult:
|
||||
def __init__(self):
|
||||
def __init__(self, phase: Phase = Phase.unknown):
|
||||
self.phase: Phase = phase
|
||||
self.dmarc: DmarcCheckResult = DmarcCheckResult.not_available
|
||||
self.spf: SPFCheckResult = SPFCheckResult.not_available
|
||||
|
||||
|
@ -291,7 +298,12 @@ class SpamdResult:
|
|||
self.spf = spf_result
|
||||
|
||||
def event_data(self) -> Dict:
|
||||
return {"header": "present", "dmarc": self.dmarc, "spf": self.spf}
|
||||
return {
|
||||
"header": "present",
|
||||
"dmarc": self.dmarc,
|
||||
"spf": self.spf,
|
||||
"phase": self.phase,
|
||||
}
|
||||
|
||||
|
||||
class Hibp(Base, ModelMixin):
|
||||
|
|
|
@ -89,6 +89,7 @@ from app.config import (
|
|||
ALERT_TO_NOREPLY,
|
||||
DMARC_CHECK_ENABLED,
|
||||
ALERT_QUARANTINE_DMARC,
|
||||
ALERT_DMARC_FAILED_REPLY_PHASE,
|
||||
)
|
||||
from app.db import Session
|
||||
from app.email import status, headers
|
||||
|
@ -158,6 +159,7 @@ from app.models import (
|
|||
Notification,
|
||||
DmarcCheckResult,
|
||||
SPFCheckResult,
|
||||
Phase,
|
||||
)
|
||||
from app.pgp_utils import PGPException, sign_data_with_pgpy, sign_data
|
||||
from app.utils import sanitize_email
|
||||
|
@ -542,10 +544,10 @@ def handle_email_sent_to_ourself(alias, from_addr: str, msg: Message, user):
|
|||
)
|
||||
|
||||
|
||||
def apply_dmarc_policy(
|
||||
def apply_dmarc_policy_for_forward_phase(
|
||||
alias: Alias, contact: Contact, envelope: Envelope, msg: Message
|
||||
) -> Optional[str]:
|
||||
spam_result = get_spamd_result(msg)
|
||||
spam_result = get_spamd_result(msg, Phase.forward)
|
||||
if not DMARC_CHECK_ENABLED or not spam_result:
|
||||
return None
|
||||
|
||||
|
@ -553,7 +555,7 @@ def apply_dmarc_policy(
|
|||
|
||||
if spam_result.dmarc == DmarcCheckResult.soft_fail:
|
||||
LOG.w(
|
||||
f"dmarc soft_fail from contact {contact.email} to alias {alias.email}."
|
||||
f"dmarc forward: dmarc soft_fail from contact {contact.email} to alias {alias.email}."
|
||||
f"mail_from:{envelope.mail_from}, from_header: {from_header}"
|
||||
)
|
||||
raise DmarcSoftFail
|
||||
|
@ -563,10 +565,10 @@ def apply_dmarc_policy(
|
|||
DmarcCheckResult.reject,
|
||||
):
|
||||
LOG.w(
|
||||
f"put email from {contact} to {alias} to quarantine. {spam_result.event_data()}, "
|
||||
f"dmarc forward: put email from {contact} to {alias} to quarantine. {spam_result.event_data()}, "
|
||||
f"mail_from:{envelope.mail_from}, from_header: {msg[headers.FROM]}"
|
||||
)
|
||||
email_log = quarantine_dmarc_failed_email(alias, contact, envelope, msg)
|
||||
email_log = quarantine_dmarc_failed_forward_email(alias, contact, envelope, msg)
|
||||
Notification.create(
|
||||
user_id=alias.user_id,
|
||||
title=f"{alias.email} has a new mail in quarantine",
|
||||
|
@ -601,7 +603,7 @@ def apply_dmarc_policy(
|
|||
return None
|
||||
|
||||
|
||||
def quarantine_dmarc_failed_email(alias, contact, envelope, msg) -> EmailLog:
|
||||
def quarantine_dmarc_failed_forward_email(alias, contact, envelope, msg) -> EmailLog:
|
||||
add_or_replace_header(msg, headers.SL_DIRECTION, "Forward")
|
||||
msg[headers.SL_ENVELOPE_TO] = alias.email
|
||||
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
|
||||
|
@ -635,6 +637,44 @@ def quarantine_dmarc_failed_email(alias, contact, envelope, msg) -> EmailLog:
|
|||
)
|
||||
|
||||
|
||||
def apply_dmarc_policy_for_reply_phase(
|
||||
alias_from: Alias, contact_recipient: Contact, envelope: Envelope, msg: Message
|
||||
) -> Optional[str]:
|
||||
spam_result = get_spamd_result(msg, Phase.reply)
|
||||
if not DMARC_CHECK_ENABLED or not spam_result:
|
||||
return None
|
||||
|
||||
if spam_result.dmarc not in (
|
||||
DmarcCheckResult.quarantine,
|
||||
DmarcCheckResult.reject,
|
||||
DmarcCheckResult.soft_fail,
|
||||
):
|
||||
return None
|
||||
LOG.w(
|
||||
f"dmarc reply: Put email from {alias_from.email} to {contact_recipient} into quarantine. {spam_result.event_data()}, "
|
||||
f"mail_from:{envelope.mail_from}, from_header: {msg[headers.FROM]}"
|
||||
)
|
||||
send_email_with_rate_control(
|
||||
alias_from.user,
|
||||
ALERT_DMARC_FAILED_REPLY_PHASE,
|
||||
alias_from.user.email,
|
||||
f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}",
|
||||
render(
|
||||
"transactional/spoof-reply.txt",
|
||||
contact=contact_recipient,
|
||||
alias=alias_from,
|
||||
sender=envelope.mail_from,
|
||||
),
|
||||
render(
|
||||
"transactional/spoof-reply.html",
|
||||
contact=contact_recipient,
|
||||
alias=alias_from,
|
||||
sender=envelope.mail_from,
|
||||
),
|
||||
)
|
||||
return status.E215
|
||||
|
||||
|
||||
def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str]]:
|
||||
"""return an array of SMTP status (is_success, smtp_status)
|
||||
is_success indicates whether an email has been delivered and
|
||||
|
@ -717,7 +757,9 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||
|
||||
# Check if we need to reject or quarantine based on dmarc
|
||||
try:
|
||||
dmarc_delivery_status = apply_dmarc_policy(alias, contact, envelope, msg)
|
||||
dmarc_delivery_status = apply_dmarc_policy_for_forward_phase(
|
||||
alias, contact, envelope, msg
|
||||
)
|
||||
if dmarc_delivery_status is not None:
|
||||
return [(False, dmarc_delivery_status)]
|
||||
except DmarcSoftFail:
|
||||
|
@ -1041,6 +1083,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
|||
Return whether an email has been delivered and
|
||||
the smtp status ("250 Message accepted", "550 Non-existent email address", etc)
|
||||
"""
|
||||
|
||||
reply_email = rcpt_to
|
||||
|
||||
# reply_email must end with EMAIL_DOMAIN
|
||||
|
@ -1076,7 +1119,14 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
|||
alias,
|
||||
contact,
|
||||
)
|
||||
return [(False, status.E504)]
|
||||
return (False, status.E504)
|
||||
|
||||
# Check if we need to reject or quarantine based on dmarc
|
||||
dmarc_delivery_status = apply_dmarc_policy_for_reply_phase(
|
||||
alias, contact, envelope, msg
|
||||
)
|
||||
if dmarc_delivery_status is not None:
|
||||
return (False, dmarc_delivery_status)
|
||||
|
||||
# Anti-spoofing
|
||||
mailbox = get_mailbox_from_mail_from(mail_from, alias)
|
||||
|
@ -2608,7 +2658,7 @@ class MailHandler:
|
|||
elapsed = time.time() - start
|
||||
# Only bounce messages if the return-path passes the spf check. Otherwise black-hole it.
|
||||
if return_status[0] == "5":
|
||||
spamd_result = get_spamd_result(msg)
|
||||
spamd_result = get_spamd_result(msg, send_event=False)
|
||||
if spamd_result and get_spamd_result(msg).spf in (
|
||||
SPFCheckResult.fail,
|
||||
SPFCheckResult.soft_fail,
|
||||
|
|
20
templates/emails/transactional/spoof-reply.html
Normal file
20
templates/emails/transactional/spoof-reply.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% call text() %}
|
||||
<h1>
|
||||
An attempt to send a fake email to {{ contact.email }} from your alias <b>{{ alias.email }}</b> using <b>{{ sender }}</b> has been blocked.
|
||||
</h1>
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
As a measure to protect against <b>email spoofing</b>, we have blocked an attempt to send an email from your alias <b>{{ alias.email }}</b> using <b>{{ sender }}</b>.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Best, <br/>
|
||||
SimpleLogin Team.
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
8
templates/emails/transactional/spoof-reply.txt
Normal file
8
templates/emails/transactional/spoof-reply.txt
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.txt.jinja2" %}
|
||||
|
||||
{% block content %}
|
||||
An attempt to send a fake email to {{ contact.email }} from your alias {{ alias.email }} using {{ sender }} has been blocked.
|
||||
|
||||
As a measure to protect against email spoofing, we have blocked an attempt to send an email from your alias {{ alias.email }} using {{ sender }}.
|
||||
{% endblock %}
|
||||
|
25
tests/example_emls/dmarc_reply_check.eml
Normal file
25
tests/example_emls/dmarc_reply_check.eml
Normal file
|
@ -0,0 +1,25 @@
|
|||
X-SimpleLogin-Client-IP: 54.39.200.130
|
||||
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
|
||||
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
|
||||
receiver=<UNKNOWN>
|
||||
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
|
||||
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
|
||||
(No client certificate requested)
|
||||
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
|
||||
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
|
||||
Date: Thu, 17 Mar 2022 16:50:18 +0000
|
||||
To: {{ contact_email }}
|
||||
From: {{ alias_email }}
|
||||
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
|
||||
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
|
||||
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
|
||||
X-Rspamd-Queue-Id: 6D8C13F069
|
||||
X-Rspamd-Server: staging1
|
||||
X-Spamd-Result: default: False [0.50 / 13.00];
|
||||
{{ dmarc_result }}(0.00)[];
|
||||
X-Rspamd-Pre-Result: action=add header;
|
||||
module=force_actions;
|
||||
unknown reason
|
||||
X-Spam: Yes
|
||||
|
||||
This is a test mailing
|
|
@ -1,9 +1,13 @@
|
|||
import random
|
||||
from email.message import EmailMessage
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from aiosmtpd.smtp import Envelope
|
||||
|
||||
import email_handler
|
||||
from app.config import BOUNCE_EMAIL
|
||||
from app.config import BOUNCE_EMAIL, EMAIL_DOMAIN, ALERT_DMARC_FAILED_REPLY_PHASE
|
||||
from app.db import Session
|
||||
from app.email import headers, status
|
||||
from app.models import (
|
||||
User,
|
||||
|
@ -12,6 +16,8 @@ from app.models import (
|
|||
IgnoredEmail,
|
||||
EmailLog,
|
||||
Notification,
|
||||
Contact,
|
||||
SentAlert,
|
||||
)
|
||||
from email_handler import (
|
||||
get_mailbox_from_mail_from,
|
||||
|
@ -75,7 +81,7 @@ def test_is_automatic_out_of_office():
|
|||
assert is_automatic_out_of_office(msg)
|
||||
|
||||
|
||||
def test_dmarc_quarantine(flask_client):
|
||||
def test_dmarc_forward_quarantine(flask_client):
|
||||
user = create_random_user()
|
||||
alias = Alias.create_new_random(user)
|
||||
msg = load_eml_file("dmarc_quarantine.eml", {"alias_email": alias.email})
|
||||
|
@ -159,3 +165,41 @@ def test_preserve_5xx_with_no_header(flask_client):
|
|||
envelope.rcpt_tos = [msg["to"]]
|
||||
result = email_handler.MailHandler()._handle(envelope, msg)
|
||||
assert result == status.E512
|
||||
|
||||
|
||||
def generate_dmarc_result() -> List:
|
||||
return ["DMARC_POLICY_QUARANTINE", "DMARC_POLICY_REJECT", "DMARC_POLICY_SOFTFAIL"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dmarc_result", generate_dmarc_result())
|
||||
def test_dmarc_reply_quarantine(dmarc_result: str):
|
||||
user = create_random_user()
|
||||
alias = Alias.create_new_random(user)
|
||||
Session.commit()
|
||||
contact = Contact.create(
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email="random-{}@nowhere.net".format(int(random.random())),
|
||||
name="Name {}".format(int(random.random())),
|
||||
reply_email="random-{}@{}".format(random.random(), EMAIL_DOMAIN),
|
||||
automatic_created=True,
|
||||
flush=True,
|
||||
commit=True,
|
||||
)
|
||||
msg = load_eml_file(
|
||||
"dmarc_reply_check.eml",
|
||||
{
|
||||
"alias_email": alias.email,
|
||||
"contact_email": contact.reply_email,
|
||||
"dmarc_result": dmarc_result,
|
||||
},
|
||||
)
|
||||
envelope = Envelope()
|
||||
envelope.mail_from = msg["from"]
|
||||
envelope.rcpt_tos = [msg["to"]]
|
||||
result = email_handler.handle(envelope, msg)
|
||||
assert result == status.E215
|
||||
alerts = SentAlert.filter_by(
|
||||
user_id=user.id, alert_type=ALERT_DMARC_FAILED_REPLY_PHASE
|
||||
).all()
|
||||
assert len(alerts) == 1
|
||||
|
|
Loading…
Reference in a new issue