mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-07 01:09:26 +00:00
Merge commit from fork
With [smtp] ssl = False (the default) or [imap] ssl = False, mailsuite upgraded the connection only if the server's plain-text EHLO/CAPABILITY reply advertised STARTTLS, and otherwise authenticated in plain text. An on-path attacker who stripped STARTTLS received the credentials and the emailed results (CWE-319). - Require mailsuite >= 2.3.4 (GHSA-2x4j-7g3q-wr6f), whose default refuses to authenticate over a connection that was not upgraded. - Add [imap] and [smtp] starttls (unset = require STARTTLS; false = explicit plain-text opt-out, for loopback servers such as DavMail), with a startup warning when the opt-out is set and ssl is False. - Add a starttls argument to email_results(). - Document both options; set starttls = False in the DavMail example. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc3b14dc0e
commit
33ca4ecf71
8 files changed
+590
-9
No files matched your search
@@ -18,6 +18,7 @@ import mailbox
|
||||
import os
|
||||
import quopri
|
||||
import time
|
||||
import traceback
|
||||
import unittest
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -5799,6 +5800,223 @@ class TestEmailResultsErrorBranches(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
def _fake_smtp_class(kind: str, calls: list, *, advertise_starttls: bool) -> type:
|
||||
"""Build a stand-in for ``smtplib.SMTP`` / ``smtplib.SMTP_SSL`` that
|
||||
appends every call it receives to ``calls``, in order, so a test can
|
||||
assert what happened on the wire and in which order (modeled on the fake
|
||||
in mailsuite's ``tests/test_smtp.py``). ``kind`` names the patched class
|
||||
in the constructor entry, and ``advertise_starttls`` decides whether the
|
||||
EHLO reply lists STARTTLS -- ``False`` plays a server, or an on-path
|
||||
attacker, that strips it (GHSA-3588-2mr7-vrcm)."""
|
||||
|
||||
class _FakeSMTP:
|
||||
def __init__(self, host="", port=0, *args, **kwargs):
|
||||
calls.append((kind, host, port))
|
||||
|
||||
def ehlo_or_helo_if_needed(self):
|
||||
calls.append(("ehlo",))
|
||||
|
||||
def ehlo(self):
|
||||
calls.append(("ehlo",))
|
||||
|
||||
def has_extn(self, name):
|
||||
return name.lower() == "starttls" and advertise_starttls
|
||||
|
||||
def starttls(self, context=None):
|
||||
calls.append(("starttls",))
|
||||
|
||||
def login(self, user, password):
|
||||
calls.append(("login", user, password))
|
||||
|
||||
def auth(self, mechanism, authobject, **kwargs):
|
||||
calls.append(("auth", mechanism))
|
||||
|
||||
def sendmail(self, sender, to, msg):
|
||||
calls.append(("sendmail", sender, sorted(to), msg))
|
||||
|
||||
return _FakeSMTP
|
||||
|
||||
|
||||
class TestEmailResultsStartTLS(unittest.TestCase):
|
||||
"""GHSA-3588-2mr7-vrcm: with ``require_encryption=False``,
|
||||
``email_results()`` used to authenticate in plain text whenever the
|
||||
server's EHLO reply did not list STARTTLS, so an on-path attacker who
|
||||
stripped STARTTLS from that reply received the SMTP credentials and the
|
||||
report. With mailsuite >= 2.3.4 the default (``starttls=None``) refuses
|
||||
to send credentials over a connection that was not upgraded, and
|
||||
``starttls=False`` is the explicit plain-text opt-out. These tests patch
|
||||
``smtplib`` itself (the SDK boundary under mailsuite's ``send_email()``)
|
||||
and assert on the recorded calls and their payloads."""
|
||||
|
||||
PASSWORD = "Sup3rSecret-placeholder"
|
||||
|
||||
def _send(self, *, advertise_starttls: bool, **kwargs) -> list:
|
||||
calls: list = []
|
||||
with (
|
||||
patch(
|
||||
"smtplib.SMTP",
|
||||
_fake_smtp_class("SMTP", calls, advertise_starttls=advertise_starttls),
|
||||
),
|
||||
patch(
|
||||
"smtplib.SMTP_SSL",
|
||||
_fake_smtp_class(
|
||||
"SMTP_SSL", calls, advertise_starttls=advertise_starttls
|
||||
),
|
||||
),
|
||||
):
|
||||
try:
|
||||
parsedmarc.email_results(
|
||||
{
|
||||
"aggregate_reports": [],
|
||||
"failure_reports": [],
|
||||
"smtp_tls_reports": [],
|
||||
},
|
||||
"smtp.example.com",
|
||||
"dmarc@example.com",
|
||||
["admin@example.com"],
|
||||
port=587,
|
||||
**kwargs,
|
||||
)
|
||||
finally:
|
||||
self.calls = calls
|
||||
return calls
|
||||
|
||||
def _names(self, calls: list) -> list:
|
||||
return [call[0] for call in calls]
|
||||
|
||||
def testCredentialsWithoutStartTLSAreRefused(self):
|
||||
"""GHSA-3588-2mr7-vrcm: by default, credentials are never sent when
|
||||
the server does not advertise STARTTLS. The send fails with
|
||||
mailsuite's SMTPError before LOGIN, nothing is sent, and the
|
||||
password appears nowhere in the error or its traceback."""
|
||||
from mailsuite.smtp import SMTPError
|
||||
|
||||
with self.assertRaises(SMTPError) as ctx:
|
||||
self._send(
|
||||
advertise_starttls=False,
|
||||
username="dmarc@example.com",
|
||||
password=self.PASSWORD,
|
||||
)
|
||||
|
||||
names = self._names(self.calls)
|
||||
self.assertEqual(names[0], "SMTP")
|
||||
self.assertNotIn("SMTP_SSL", names)
|
||||
self.assertNotIn("login", names)
|
||||
self.assertNotIn("auth", names)
|
||||
self.assertNotIn("sendmail", names)
|
||||
self.assertIn("STARTTLS", str(ctx.exception))
|
||||
formatted = "".join(
|
||||
traceback.format_exception(
|
||||
type(ctx.exception), ctx.exception, ctx.exception.__traceback__
|
||||
)
|
||||
)
|
||||
self.assertNotIn(self.PASSWORD, formatted)
|
||||
|
||||
def testStartTLSPrecedesLoginAndSend(self):
|
||||
"""When the server advertises STARTTLS, the connection is upgraded
|
||||
before LOGIN, LOGIN gets the configured credentials, and the report
|
||||
is sent last (GHSA-3588-2mr7-vrcm)."""
|
||||
calls = self._send(
|
||||
advertise_starttls=True,
|
||||
username="dmarc@example.com",
|
||||
password=self.PASSWORD,
|
||||
)
|
||||
|
||||
names = self._names(calls)
|
||||
self.assertEqual(calls[0], ("SMTP", "smtp.example.com", 587))
|
||||
self.assertLess(names.index("starttls"), names.index("login"))
|
||||
self.assertLess(names.index("login"), names.index("sendmail"))
|
||||
login = calls[names.index("login")]
|
||||
self.assertEqual(login, ("login", "dmarc@example.com", self.PASSWORD))
|
||||
sendmail = calls[names.index("sendmail")]
|
||||
self.assertEqual(sendmail[1], "dmarc@example.com")
|
||||
self.assertEqual(sendmail[2], ["admin@example.com"])
|
||||
self.assertIn("Subject: DMARC results for ", sendmail[3])
|
||||
self.assertIn('filename="DMARC-', sendmail[3])
|
||||
|
||||
def testRequireEncryptionUsesImplicitTLS(self):
|
||||
"""``require_encryption=True`` connects with ``SMTP_SSL`` and never
|
||||
issues STARTTLS, whatever ``starttls`` says (GHSA-3588-2mr7-vrcm)."""
|
||||
for starttls in (None, True, False):
|
||||
with self.subTest(starttls=starttls):
|
||||
calls = self._send(
|
||||
advertise_starttls=False,
|
||||
require_encryption=True,
|
||||
starttls=starttls,
|
||||
username="dmarc@example.com",
|
||||
password=self.PASSWORD,
|
||||
)
|
||||
names = self._names(calls)
|
||||
self.assertEqual(calls[0], ("SMTP_SSL", "smtp.example.com", 587))
|
||||
self.assertNotIn("SMTP", names)
|
||||
self.assertNotIn("starttls", names)
|
||||
self.assertLess(names.index("login"), names.index("sendmail"))
|
||||
|
||||
def testNoCredentialsWithoutStartTLSSendsWithWarning(self):
|
||||
"""Without credentials there is nothing to steal at login, so a
|
||||
server without STARTTLS still gets the message in plain text, with
|
||||
mailsuite's warning (GHSA-3588-2mr7-vrcm scope)."""
|
||||
with self.assertLogs("mailsuite.smtp", level="WARNING") as logs:
|
||||
calls = self._send(advertise_starttls=False)
|
||||
|
||||
names = self._names(calls)
|
||||
self.assertNotIn("starttls", names)
|
||||
self.assertNotIn("login", names)
|
||||
self.assertEqual(names[-1], "sendmail")
|
||||
self.assertTrue(
|
||||
any("does not support STARTTLS" in line for line in logs.output),
|
||||
logs.output,
|
||||
)
|
||||
|
||||
def testStartTLSFalseAuthenticatesInPlainText(self):
|
||||
"""``starttls=False`` is the explicit opt-out from
|
||||
GHSA-3588-2mr7-vrcm's fix: credentials are sent without STARTTLS,
|
||||
with a warning."""
|
||||
with self.assertLogs("mailsuite.smtp", level="WARNING") as logs:
|
||||
calls = self._send(
|
||||
advertise_starttls=False,
|
||||
starttls=False,
|
||||
username="dmarc@example.com",
|
||||
password=self.PASSWORD,
|
||||
)
|
||||
|
||||
names = self._names(calls)
|
||||
self.assertNotIn("starttls", names)
|
||||
self.assertEqual(
|
||||
calls[names.index("login")],
|
||||
("login", "dmarc@example.com", self.PASSWORD),
|
||||
)
|
||||
self.assertLess(names.index("login"), names.index("sendmail"))
|
||||
self.assertTrue(any("unencrypted" in line for line in logs.output), logs.output)
|
||||
|
||||
def testStartTLSFalseNeverUpgradesEvenWhenAdvertised(self):
|
||||
"""``starttls=False`` never upgrades, even when the server
|
||||
advertises STARTTLS (GHSA-3588-2mr7-vrcm opt-out semantics)."""
|
||||
calls = self._send(
|
||||
advertise_starttls=True,
|
||||
starttls=False,
|
||||
username="dmarc@example.com",
|
||||
password=self.PASSWORD,
|
||||
)
|
||||
|
||||
names = self._names(calls)
|
||||
self.assertNotIn("starttls", names)
|
||||
self.assertLess(names.index("login"), names.index("sendmail"))
|
||||
|
||||
def testStartTLSTrueRefusesWithoutAdvertisement(self):
|
||||
"""``starttls=True`` requires the upgrade: no STARTTLS in the EHLO
|
||||
reply raises before anything is sent, even without credentials
|
||||
(GHSA-3588-2mr7-vrcm)."""
|
||||
from mailsuite.smtp import SMTPError
|
||||
|
||||
with self.assertRaises(SMTPError):
|
||||
self._send(advertise_starttls=False, starttls=True)
|
||||
|
||||
names = self._names(self.calls)
|
||||
self.assertNotIn("sendmail", names)
|
||||
self.assertNotIn("login", names)
|
||||
|
||||
|
||||
class TestEmailResultsViaMsGraph(unittest.TestCase):
|
||||
"""email_results_via_msgraph() shares its
|
||||
subject/message/attachment-building logic with email_results() via the
|
||||
|
||||
Reference in new issue
Block a user