enforce encryption for in-server mails (#535)

* enforce encryption for in-server mails

* make tests work with chatmail server only support e2ee internally

* fix echobot test

* simplify quota-exceeded test

* work around rpc-server fixture changes
This commit is contained in:
holger krekel
2025-03-29 21:22:26 +01:00
committed by GitHub
parent ce240083c4
commit 194030a456
9 changed files with 94 additions and 75 deletions

View File

@@ -2,6 +2,9 @@
## untagged ## untagged
- Enforce end-to-end encryption between local addresses
([#535](https://github.com/chatmail/server/pull/535))
- Limit the bind for the HTTPS server on 8443 to 127.0.0.1 - Limit the bind for the HTTPS server on 8443 to 127.0.0.1
([#522](https://github.com/chatmail/server/pull/522)) ([#522](https://github.com/chatmail/server/pull/522))
([#532](https://github.com/chatmail/server/pull/532)) ([#532](https://github.com/chatmail/server/pull/532))

View File

@@ -8,6 +8,7 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
@@ -97,6 +98,10 @@ def main():
if not bot.is_configured(): if not bot.is_configured():
bot.configure(addr, password) bot.configure(addr, password)
# write invite link to working directory
invitelink = bot.account.get_qr_code()
Path("invite-link.txt").write_text(invitelink)
bot.run_forever() bot.run_forever()

View File

@@ -209,7 +209,6 @@ class BeforeQueueHandler:
mail_encrypted = check_encrypted(message) mail_encrypted = check_encrypted(message)
_, from_addr = parseaddr(message.get("from").strip()) _, from_addr = parseaddr(message.get("from").strip())
envelope_from_domain = from_addr.split("@").pop()
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}") logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
if envelope.mail_from.lower() != from_addr.lower(): if envelope.mail_from.lower() != from_addr.lower():
@@ -223,26 +222,16 @@ class BeforeQueueHandler:
if envelope.mail_from in self.config.passthrough_senders: if envelope.mail_from in self.config.passthrough_senders:
return return
passthrough_recipients = self.config.passthrough_recipients
if mail_encrypted or is_securejoin(message): if mail_encrypted or is_securejoin(message):
return return
passthrough_recipients = self.config.passthrough_recipients
for recipient in envelope.rcpt_tos: for recipient in envelope.rcpt_tos:
if envelope.mail_from == recipient:
# Always allow sending emails to self.
continue
if recipient_matches_passthrough(recipient, passthrough_recipients): if recipient_matches_passthrough(recipient, passthrough_recipients):
continue continue
res = recipient.split("@") print("Rejected unencrypted mail.", file=sys.stderr)
if len(res) != 2: return "500 Invalid unencrypted mail"
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
is_outgoing = recipient_domain != envelope_from_domain
if is_outgoing:
print("Rejected unencrypted mail.", file=sys.stderr)
return f"500 Invalid unencrypted mail to <{recipient}>"
class SendRateLimiter: class SendRateLimiter:

View File

@@ -72,9 +72,8 @@ def maildata(request):
def maildata(name, from_addr, to_addr, subject="[...]"): def maildata(name, from_addr, to_addr, subject="[...]"):
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines. # Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
data = datadir.joinpath(name).read_bytes().decode() data = datadir.joinpath(name).read_bytes().decode()
text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject) text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject)
return BytesParser(policy=policy.default).parsebytes(text.encode()) return BytesParser(policy=policy.SMTP).parsebytes(text.encode())
return maildata return maildata

View File

@@ -29,14 +29,14 @@ def test_reject_forged_from(maildata, gencreds, handler):
# test that the filter lets good mail through # test that the filter lets good mail through
to_addr = gencreds()[0] to_addr = gencreds()[0]
env.content = maildata( env.content = maildata(
"plain.eml", from_addr=env.mail_from, to_addr=to_addr "encrypted.eml", from_addr=env.mail_from, to_addr=to_addr
).as_bytes() ).as_bytes()
assert not handler.check_DATA(envelope=env) assert not handler.check_DATA(envelope=env)
# test that the filter rejects forged mail # test that the filter rejects forged mail
env.content = maildata( env.content = maildata(
"plain.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr "encrypted.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
).as_bytes() ).as_bytes()
error = handler.check_DATA(envelope=env) error = handler.check_DATA(envelope=env)
assert "500" in error assert "500" in error
@@ -106,7 +106,7 @@ def test_send_rate_limiter():
break break
def test_excempt_privacy(maildata, gencreds, handler): def test_cleartext_excempt_privacy(maildata, gencreds, handler):
from_addr = gencreds()[0] from_addr = gencreds()[0]
to_addr = "privacy@testrun.org" to_addr = "privacy@testrun.org"
handler.config.passthrough_recipients = [to_addr] handler.config.passthrough_recipients = [to_addr]
@@ -130,7 +130,22 @@ def test_excempt_privacy(maildata, gencreds, handler):
assert "500" in handler.check_DATA(envelope=env2) assert "500" in handler.check_DATA(envelope=env2)
def test_passthrough_domains(maildata, gencreds, handler): def test_cleartext_self_send_fails(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = from_addr
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
res = handler.check_DATA(envelope=env)
assert "500 Invalid unencrypted" in res
def test_cleartext_passthrough_domains(maildata, gencreds, handler):
from_addr = gencreds()[0] from_addr = gencreds()[0]
to_addr = "privacy@x.y.z" to_addr = "privacy@x.y.z"
handler.config.passthrough_recipients = ["@x.y.z"] handler.config.passthrough_recipients = ["@x.y.z"]
@@ -154,7 +169,7 @@ def test_passthrough_domains(maildata, gencreds, handler):
assert "500" in handler.check_DATA(envelope=env2) assert "500" in handler.check_DATA(envelope=env2)
def test_passthrough_senders(gencreds, handler, maildata): def test_cleartext_passthrough_senders(gencreds, handler, maildata):
acc1 = gencreds()[0] acc1 = gencreds()[0]
to_addr = "recipient@something.org" to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1] handler.config.passthrough_senders = [acc1]

View File

@@ -14,3 +14,22 @@ def shell(command, fail_ok=False):
def get_systemd_running(): def get_systemd_running():
lines = shell("systemctl --type=service --state=running").split("\n") lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")] return [line for line in lines if line.startswith(" ")]
def write_numbytes(path, num):
with open(path, "w") as f:
f.write("x" * num)
def dovecot_recalc_quota(user):
shell(f"doveadm quota recalc -u {user}")
output = shell(f"doveadm quota get -u {user}")
#
# Quota name Type Value Limit %
# User quota STORAGE 5 102400 0
# User quota MESSAGE 2 - 0
#
for line in output.split("\n"):
parts = line.split()
if parts[2] == "STORAGE":
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))

View File

@@ -116,9 +116,8 @@ def test_authenticated_from(cmsetup, maildata):
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"]) @pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr): def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
recipient = cmsetup.gen_users(1)[0] recipient = cmsetup.gen_users(1)[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string() msg = maildata("encrypted.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
with smtplib.SMTP(cmsetup.maildomain, 25) as s: with smtplib.SMTP(cmsetup.maildomain, 25) as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"): with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)

View File

@@ -1,5 +1,4 @@
import ipaddress import ipaddress
import random
import re import re
import time import time
@@ -7,6 +6,9 @@ import imap_tools
import pytest import pytest
import requests import requests
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
@pytest.fixture @pytest.fixture
def imap_mailbox(cmfactory): def imap_mailbox(cmfactory):
@@ -54,22 +56,23 @@ class TestEndToEndDeltaChat:
"""Test that a DC account can send a message to a second DC account """Test that a DC account can send a message to a second DC account
on the same chat-mail instance.""" on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2) chat = cmfactory.get_protected_chat(ac1, ac2)
lp.sec("ac1: prepare and send text message to ac2")
chat.send_text("message0") chat.send_text("message0")
lp.sec("wait for ac2 to receive message") lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message() msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0" assert msg2.text == "message0"
@pytest.mark.slow def test_exceed_quota(
def test_exceed_quota(self, cmfactory, lp, tmpdir, remote, chatmail_config): self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain
):
"""This is a very slow test as it needs to upload >100MB of mail data """This is a very slow test as it needs to upload >100MB of mail data
before quota is exceeded, and thus depends on the speed of the upload. before quota is exceeded, and thus depends on the speed of the upload.
""" """
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2) chat = cmfactory.get_protected_chat(ac1, ac2)
user = ac2.get_config("configured_addr")
def parse_size_limit(limit: str) -> int: def parse_size_limit(limit: str) -> int:
"""Parse a size limit and return the number of bytes as integer. """Parse a size limit and return the number of bytes as integer.
@@ -82,49 +85,27 @@ class TestEndToEndDeltaChat:
return int(float(number) * units[unit]) return int(float(number) * units[unit])
quota = parse_size_limit(chatmail_config.max_mailbox_size) quota = parse_size_limit(chatmail_config.max_mailbox_size)
attachsize = 1 * 1024 * 1024
num_to_send = quota // attachsize + 2
lp.sec(f"ac1: send {num_to_send} large files to ac2")
lp.indent(f"per-user quota is assumed to be: {quota / (1024 * 1024)}MB")
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
msgs = []
for i in range(num_to_send):
attachment = tmpdir / f"attachment{i}"
data = "".join(random.choice(alphanumeric) for i in range(1024))
with open(attachment, "w+") as f:
for j in range(attachsize // len(data)):
f.write(data)
msg = chat.send_file(str(attachment)) lp.sec(f"filling remote inbox for {user}")
msgs.append(msg) fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
lp.indent(f"Sent out msg {i}, size {attachsize / (1024 * 1024)}MB") path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
sshexec = SSHExec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100
lp.sec("ac2: check messages are arriving until quota is reached") lp.sec("ac2: check quota is triggered")
addr = ac2.get_config("addr").lower() starting = True
saved_ok = 0
for line in remote.iter_output("journalctl -n0 -f -u dovecot"): for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if addr not in line: if starting:
chat.send_text("hello")
starting = False
if user not in line:
# print(line) # print(line)
continue continue
if "quota" in line: if "quota exceeded" in line:
if "quota exceeded" in line: return
if saved_ok < num_to_send // 2:
pytest.fail(
f"quota exceeded too early: after {saved_ok} messages already"
)
lp.indent("good, message sending failed because quota was exceeded")
return
if (
"stored mail into mailbox 'inbox'" in line
or "saved mail to inbox" in line
):
saved_ok += 1
print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send:
break
pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(self, cmfactory, lp, maildomain2): def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.new_online_configuring_account(cache=False)
@@ -172,7 +153,7 @@ def test_hide_senders_ip_address(cmfactory):
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
user1, user2 = cmfactory.get_online_accounts(2) user1, user2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(user1, user2) chat = cmfactory.get_protected_chat(user1, user2)
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()
@@ -181,11 +162,18 @@ def test_hide_senders_ip_address(cmfactory):
assert public_ip not in msg.obj.as_string() assert public_ip not in msg.obj.as_string()
def test_echobot(cmfactory, chatmail_config, lp): def test_echobot(cmfactory, chatmail_config, lp, sshdomain):
ac = cmfactory.get_online_accounts(1)[0] ac = cmfactory.get_online_accounts(1)[0]
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}") # establish contact with echobot
chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}") sshexec = SSHExec(sshdomain)
command = "cat /var/lib/echobot/invite-link.txt"
echo_invite_link = sshexec(call=rshell.shell, kwargs=dict(command=command))
chat = ac.qr_setup_contact(echo_invite_link)
ac._evtracker.wait_securejoin_joiner_progress(1000)
# send message and check it gets replied back
lp.sec(f"Send message to echobot")
text = "hi, I hope you text me back" text = "hi, I hope you text me back"
chat.send_text(text) chat.send_text(text)
lp.sec("Wait for reply from echobot") lp.sec("Wait for reply from echobot")

View File

@@ -302,10 +302,12 @@ def cmfactory(request, gencreds, tmpdir, maildomain):
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
data = request.getfixturevalue("data")
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
class Data:
def read_path(self, path):
return
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# nb. a bit hacky # nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support # would probably be better if deltachat's test machinery grows native support