From c6d8f7e7593a45ead8a36ebfdcc4ed9f5235cbdc Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Oct 2023 13:10:28 +0200 Subject: [PATCH] initial forged-from protection --- chatmaild/src/chatmaild/dictproxy.py | 4 +- chatmaild/src/chatmaild/filtermail.py | 92 +++++++++++++--------- chatmaild/src/chatmaild/test_filtermail.py | 39 ++++++++- online-tests/conftest.py | 10 +++ online-tests/mailgen/encrypted.eml | 66 ++++++++++++++++ online-tests/test_0_basic.py | 45 +++++++++++ 6 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 online-tests/mailgen/encrypted.eml diff --git a/chatmaild/src/chatmaild/dictproxy.py b/chatmaild/src/chatmaild/dictproxy.py index 8e2698b5..4250c6e9 100644 --- a/chatmaild/src/chatmaild/dictproxy.py +++ b/chatmaild/src/chatmaild/dictproxy.py @@ -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) diff --git a/chatmaild/src/chatmaild/filtermail.py b/chatmaild/src/chatmaild/filtermail.py index 368f1762..f7dfa3a5 100644 --- a/chatmaild/src/chatmaild/filtermail.py +++ b/chatmaild/src/chatmaild/filtermail.py @@ -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() diff --git a/chatmaild/src/chatmaild/test_filtermail.py b/chatmaild/src/chatmaild/test_filtermail.py index 7f5a5811..4bcae368 100644 --- a/chatmaild/src/chatmaild/test_filtermail.py +++ b/chatmaild/src/chatmaild/test_filtermail.py @@ -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: ", + "Date: Sun, 15 Oct 2023 16:41:44 +0000", + "Message-ID: ", + "References: ", + "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) diff --git a/online-tests/conftest.py b/online-tests/conftest.py index 9f5aba0d..4be5057f 100644 --- a/online-tests/conftest.py +++ b/online-tests/conftest.py @@ -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() diff --git a/online-tests/mailgen/encrypted.eml b/online-tests/mailgen/encrypted.eml new file mode 100644 index 00000000..181d4350 --- /dev/null +++ b/online-tests/mailgen/encrypted.eml @@ -0,0 +1,66 @@ +From: {from_addr} +To: {to_addr} +Subject: ... +Date: Sun, 15 Oct 2023 16:43:21 +0000 +Message-ID: +In-Reply-To: +References: + +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-- + + diff --git a/online-tests/test_0_basic.py b/online-tests/test_0_basic.py index a585d5e2..4244762b 100644 --- a/online-tests/test_0_basic.py +++ b/online-tests/test_0_basic.py @@ -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")