From b9387743835821e8327e73aa502cb01f2f83dc97 Mon Sep 17 00:00:00 2001 From: Patrick Cernko Date: Wed, 19 Oct 2022 23:27:10 +0000 Subject: [PATCH] Verify recipient validity at RCPT command in LMTP runner (2nd try) Verify recipient validity at RCPT command in LMTP runner (reviewed merge request c2ddff05a4f405fa46fb792cc69912829c1cbf83, rejected 2 years ago) * returning '250 ok' to correctly accept valid recipients * reorganized code a bit to better match existing recipient verification code in handle_DATA() * fixed unit tests (verified with `tox -e py39-nocov` on Debian/bullseye) See !671 for the original merge request by @foxcpp. Also see !126. Fixes #14 --- src/mailman/docs/NEWS.rst | 5 ++++ src/mailman/docs/mta.rst | 38 ++++++++++++++++++++++++++++ src/mailman/runners/lmtp.py | 38 ++++++++++++++++++++++++++++ src/mailman/runners/tests/test_lmtp.py | 45 +++++++++++++++++++++++++++------- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 83b535e97..b257162e2 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -89,6 +89,11 @@ Command line * The ``mailman members`` command now has a ``--count-only`` option. (Closes #1030) +New Features +------------ +* The LMTP service now only accept ``RCPT TO`` commands if the given + recipient address is acceptable (See !1043). + .. _news-3.3.5: 3.3.5 diff --git a/src/mailman/docs/mta.rst b/src/mailman/docs/mta.rst index bd2df6594..cc85910b2 100644 --- a/src/mailman/docs/mta.rst +++ b/src/mailman/docs/mta.rst @@ -253,6 +253,8 @@ which are local, you may need ``local_recipient_maps`` as above. Note that these can be ``regexp`` tables rather than ``hash`` tables. See the ``Transport maps`` section above. +Starting with version 3.3.6, it is possible to use Mailman's LMTP +service with Postfix' ``reject_unverified_recipient``. Postfix documentation --------------------- @@ -381,6 +383,42 @@ two lines to the ``mailman3_transport:`` section. headers_remove = message-id headers_add = "Message-ID: ${if def:header_message-id:{$h_message-id:}{}}" +Alternative setup using callout verification +-------------------------------------------- + +Starting with version 3.3.6, you can rely on Mailman's responce on +``RCPT TO:`` LMTP command if mailman would accept the recipient +address as valid. This can be used in Exim to validate recipients +using callout verification. +:: + + # /etc/exim4/conf.d/main/25_mm3_macros + # The colon-separated list of domains served by Mailman. + domainlist mm_domains = list.example.net + # The port of your Mailman's LMTP service + MM3_LMTP_PORT = 8024 + + # /etc/exim4/local_rcpt_callout (create file or append if already exists) + # Make callout verification for all domains served by Mailman. + *@+mm_domains + + # /etc/exim4/conf.d/router/455_mm3_router + mailman3_router: + driver = accept + domains = +mm_domains + # no further conditions, valid recipients are verified in + # acl_check_rcpt using callout verification + transport = mailman3_transport + + # /etc/exim4/conf.d/transport/55_mm3_transport + mailman3_transport: + driver = smtp + protocol = lmtp + allow_localhost + hosts = localhost + port = MM3_LMTP_PORT + rcpt_include_affixes = true + Exim 4 documentation -------------------- diff --git a/src/mailman/runners/lmtp.py b/src/mailman/runners/lmtp.py index 53318f712..b5b5b181e 100644 --- a/src/mailman/runners/lmtp.py +++ b/src/mailman/runners/lmtp.py @@ -128,6 +128,44 @@ def split_recipient(address): class LMTPHandler: @asyncio.coroutine @transactional + def handle_RCPT(self, server, session, envelope, to, rcpt_options): + listnames = set(getUtility(IListManager).names) + try: + to = parseaddr(to)[1].lower() + local, subaddress, domain = split_recipient(to) + if subaddress is not None: + # Check that local-subaddress is not an actual list name. + listname = '{}-{}@{}'.format(local, subaddress, domain) + if listname in listnames: + local = '{}-{}'.format(local, subaddress) + subaddress = None + listname = '{}@{}'.format(local, domain) + if listname not in listnames: + return ERR_550 + canonical_subaddress = SUBADDRESS_NAMES.get(subaddress) + if subaddress is None: + # The message is destined for the mailing list. + # nothing to do here, just keep code similar to handle_DATA + pass + elif canonical_subaddress is None: + # The subaddress was bogus. + slog.error('unknown sub-address: %s', subaddress) + return ERR_550 + else: + # A valid subaddress. + # nothing to do here, just keep code similar to handle_DATA + pass + # recipient validated, just do the same as aiosmtpd.LMTP would do + envelope.rcpt_tos.append(to) + envelope.rcpt_options.extend(rcpt_options) + return '250 Ok' + except Exception: + slog.exception('Address verification: %s', to) + config.db.abort() + return ERR_550 + + @asyncio.coroutine + @transactional def handle_DATA(self, server, session, envelope): try: # Refresh the list of list names every time we process a message diff --git a/src/mailman/runners/tests/test_lmtp.py b/src/mailman/runners/tests/test_lmtp.py index 3d0f8e0e5..520022802 100644 --- a/src/mailman/runners/tests/test_lmtp.py +++ b/src/mailman/runners/tests/test_lmtp.py @@ -114,7 +114,7 @@ Message-ID: def test_nonexistent_mailing_list(self): # Trying to post to a nonexistent mailing list is an error. - with self.assertRaises(smtplib.SMTPDataError) as cm: + with self.assertRaises(smtplib.SMTPRecipientsRefused) as cm: self._lmtp.sendmail('anne@example.com', ['notalist@example.com'], """\ From: anne.person@example.com @@ -123,13 +123,22 @@ Subject: An interesting message Message-ID: """) - self.assertEqual(cm.exception.smtp_code, 550) - self.assertEqual(cm.exception.smtp_error, + # smtplib.SMTPRecipientsRefused.args contains a list of errors (for + # each RCPT TO), thus we should have only one error + self.assertEqual(len(cm.exception.args), 1) + args0 = cm.exception.args[0] + # each error should be a dict with the corresponding email address + # as key + self.assertTrue('notalist@example.com' in args0) + errorval = args0['notalist@example.com'] + # errorval must be a tuple of (code, errorstr) + self.assertEqual(errorval[0], 550) + self.assertEqual(errorval[1], b'Requested action not taken: mailbox unavailable') def test_nonexistent_domain(self): # Trying to post to a nonexistent domain is an error. - with self.assertRaises(smtplib.SMTPDataError) as cm: + with self.assertRaises(smtplib.SMTPRecipientsRefused) as cm: self._lmtp.sendmail('anne@example.com', ['test@x.example.com'], """\ From: anne.person@example.com @@ -138,8 +147,17 @@ Subject: An interesting message Message-ID: """) - self.assertEqual(cm.exception.smtp_code, 550) - self.assertEqual(cm.exception.smtp_error, + # smtplib.SMTPRecipientsRefused.args contains a list of errors (for + # each RCPT TO), thus we should have only one error + self.assertEqual(len(cm.exception.args), 1) + args0 = cm.exception.args[0] + # each error should be a dict with the corresponding email address + # as key + self.assertTrue('test@x.example.com' in args0) + errorval = args0['test@x.example.com'] + # errorval must be a tuple of (code, errorstr) + self.assertEqual(errorval[0], 550) + self.assertEqual(errorval[1], b'Requested action not taken: mailbox unavailable') def test_alias_domain(self): @@ -168,7 +186,7 @@ X-MailFrom: anne@example.com def test_missing_subaddress(self): # Trying to send a message to a bogus subaddress is an error. - with self.assertRaises(smtplib.SMTPDataError) as cm: + with self.assertRaises(smtplib.SMTPRecipientsRefused) as cm: self._lmtp.sendmail('anne@example.com', ['test-bogus@example.com'], """\ From: anne.person@example.com @@ -177,8 +195,17 @@ Subject: An interesting message Message-ID: """) - self.assertEqual(cm.exception.smtp_code, 550) - self.assertEqual(cm.exception.smtp_error, + # smtplib.SMTPRecipientsRefused.args contains a list of errors (for + # each RCPT TO), thus we should have only one error + self.assertEqual(len(cm.exception.args), 1) + args0 = cm.exception.args[0] + # each error should be a dict with the corresponding email address + # as key + self.assertTrue('test-bogus@example.com' in args0) + errorval = args0['test-bogus@example.com'] + # errorval must be a tuple of (code, errorstr) + self.assertEqual(errorval[0], 550) + self.assertEqual(errorval[1], b'Requested action not taken: mailbox unavailable') def test_mailing_list_with_subaddress(self): -- 2.11.4.GIT