initial forged-from protection

This commit is contained in:
holger krekel
2023-10-18 13:10:28 +02:00
parent 76765164dc
commit c6d8f7e759
6 changed files with 215 additions and 41 deletions

View File

@@ -31,7 +31,9 @@ def encrypt_password(password: str):
def create_user(db, user, password):
if os.path.exists(NOCREATE_FILE):
logging.warning(f"Didn't create account: {NOCREATE_FILE} exists. Delete the file to enable account creation.")
logging.warning(
f"Didn't create account: {NOCREATE_FILE} exists. Delete the file to enable account creation."
)
return
with db.write_transaction() as conn:
conn.create_user(user, password)

View File

@@ -3,6 +3,7 @@ import asyncio
import logging
from email.parser import BytesParser
from email import policy
from email.utils import parseaddr
from aiosmtpd.lmtp import LMTP
from aiosmtpd.controller import UnixSocketController
@@ -42,50 +43,14 @@ class ExampleHandler:
return "250 OK"
async def handle_DATA(self, server, session, envelope):
logging.info("Processing DATA message from %s", envelope.mail_from)
valid_recipients = []
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
res = []
for recipient in envelope.rcpt_tos:
my_local_domain = envelope.mail_from.split("@")
if len(my_local_domain) != 2:
res += [f"500 Invalid from address <{envelope.mail_from}>"]
continue
if envelope.mail_from == recipient:
# Always allow sending emails to self.
valid_recipients += [recipient]
res += ["250 OK"]
continue
recipient_local_domain = recipient.split("@")
if len(recipient_local_domain) != 2:
res += [f"500 Invalid address <{recipient}>"]
continue
is_outgoing = recipient_local_domain[1] != my_local_domain[1]
if (
is_outgoing
and not mail_encrypted
and message.get("secure-join") != "vc-request"
and message.get("secure-join") != "vg-request"
):
res += ["500 Outgoing mail must be encrypted"]
continue
valid_recipients += [recipient]
res += ["250 OK"]
valid_recipients, res = lmtp_handle_DATA(envelope)
# Reinject the mail back into Postfix.
if valid_recipients:
logging.info("Reinjecting the mail")
client = SMTPClient("localhost", "10026")
client.sendmail(envelope.mail_from, valid_recipients, envelope.content)
else:
logging.info("no valid recipients, ignoring mail")
return "\r\n".join(res)
@@ -97,6 +62,55 @@ async def asyncmain(loop):
controller.start()
def lmtp_handle_DATA(envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
valid_recipients = []
res = []
for recipient in envelope.rcpt_tos:
my_local_domain = envelope.mail_from.split("@")
if len(my_local_domain) != 2:
res += [f"500 Invalid from address <{envelope.mail_from}>"]
continue
_, from_addr = parseaddr(message.get("from").strip().lower())
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from}")
if envelope.mail_from != from_addr:
res += [f"500 Invalid FROM <{envelope.mail_from}>"]
continue
if envelope.mail_from == recipient:
# Always allow sending emails to self.
valid_recipients += [recipient]
res += ["250 OK"]
continue
recipient_local_domain = recipient.split("@")
if len(recipient_local_domain) != 2:
res += [f"500 Invalid address <{recipient}>"]
continue
is_outgoing = recipient_local_domain[1] != my_local_domain[1]
if (
is_outgoing
and not mail_encrypted
and message.get("secure-join") != "vc-request"
and message.get("secure-join") != "vg-request"
):
res += ["500 Outgoing mail must be encrypted"]
continue
valid_recipients += [recipient]
res += ["250 OK"]
return valid_recipients, res
def main():
logging.basicConfig(level=logging.INFO)
loop = asyncio.new_event_loop()

View File

@@ -1,8 +1,45 @@
from .filtermail import check_encrypted
from .filtermail import check_encrypted, lmtp_handle_DATA
from email.parser import BytesParser
from email import policy
def test_reject_forged_from():
def makemail(from_addr):
return BytesParser(policy=policy.default).parsebytes(
"\r\n".join(
[
f"From: <{from_addr}",
"To: <barbaz@c3.testrun.org>",
"Date: Sun, 15 Oct 2023 16:41:44 +0000",
"Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
"Chat-Version: 1.0",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
"",
"Hi!",
"",
"",
]
).encode()
)
class envelope:
mail_from = "bob@c3.testrun.org"
rcpt_tos = ["somebody@c3.testrun.org"]
envelope.content = makemail(envelope.mail_from).as_bytes()
valid_recipients, res = lmtp_handle_DATA(envelope=envelope)
assert valid_recipients == envelope.rcpt_tos
assert len(res) == 1 and "250" in res[0]
envelope.content = makemail("forged@c3.testrun.org").as_bytes()
valid_recipients, res = lmtp_handle_DATA(envelope=envelope)
assert not valid_recipients
assert len(res) == 1 and "500" in res[0]
def test_filtermail():
def check_encrypted_bstr(content):
message = BytesParser(policy=policy.default).parsebytes(content)

View File

@@ -199,3 +199,13 @@ class Remote:
while 1:
line = self.popen.stdout.readline()
yield line.decode().strip().lower()
@pytest.fixture
def mailgen(request):
class Mailgen:
def get_encrypted(self, from_addr, to_addr):
data = request.fspath.dirpath("mailgen/encrypted.eml").read()
return data.format(from_addr=from_addr, to_addr=to_addr)
return Mailgen()

View File

@@ -0,0 +1,66 @@
From: {from_addr}
To: {to_addr}
Subject: ...
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
MIME-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: PGP/MIME version identification
Content-Type: application/pgp-encrypted
Version: 1
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: OpenPGP encrypted message
Content-Disposition: inline; filename="encrypted.asc";
Content-Type: application/octet-stream; name="encrypted.asc"
-----BEGIN PGP MESSAGE-----
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
lkOWnEbCD+XTnbDd
=agR5
-----END PGP MESSAGE-----
--YFrteb74qSXmggbOxZL9dRnhymywAi--

View File

@@ -1,3 +1,7 @@
import smtplib
import pytest
def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
imap_or_smtp.connect()
@@ -13,3 +17,44 @@ def test_use_two_chatmailservers(cmfactory, maildomain2):
domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1]
assert domain1 != domain2
def test_reject_internal_forged_from(smtp, gencreds, mailgen, lp, imap):
lp.sec("create forged user account")
forged_user, password = gencreds()
smtp.connect()
smtp.login(forged_user, password)
lp.sec("create TO user account")
to_user, password = gencreds()
smtp.connect()
smtp.login(to_user, password)
lp.sec("create account")
login_user, login_password = gencreds()
smtp.connect()
smtp.login(login_user, login_password)
lp.sec("send encrypted message with forged from")
print("envelope_from", login_user)
print("logged in as", login_user)
print("injected mime message")
msg = mailgen.get_encrypted(from_addr=forged_user, to_addr=to_user)
for line in msg.split("\n")[:4]:
print(f" {line}")
smtp.conn.sendmail(from_addr=login_user, to_addrs=[to_user], msg=msg)
imap.connect()
imap.login(login_user, login_password)
imap.conn.select("Inbox")
# detect mailer daemon rejection message
status, results = imap.conn.fetch("1", "(RFC822)")
assert status == "OK"
for res in results:
message = res[1].decode()
if "Invalid FROM" in message and forged_user in message:
return
pytest.fail("forged From did not cause rejection")