From 990f7226da9deb667e6b026ba7ae24ef728900c4 Mon Sep 17 00:00:00 2001 From: Jim Popovitch Date: Sun, 3 Mar 2013 03:04:37 -0500 Subject: Hold/Reject/Discard moderation support for Senders with a DMARC p=reject policy --- Mailman/Gui/Privacy.py | 24 ++++++++++++++++++++++++ Mailman/Handlers/Moderate.py | 25 +++++++++++++++++++++++++ Mailman/MailList.py | 2 ++ Mailman/Utils.py | 39 +++++++++++++++++++++++++++++++++++++++ Mailman/versions.py | 2 ++ 5 files changed, 92 insertions(+) (limited to 'Mailman') diff --git a/Mailman/Gui/Privacy.py b/Mailman/Gui/Privacy.py index 75eff2b5..ae78b209 100644 --- a/Mailman/Gui/Privacy.py +++ b/Mailman/Gui/Privacy.py @@ -235,6 +235,30 @@ class Privacy(GUIBase): >rejection notice to be sent to moderated members who post to this list.""")), + ('dmarc_moderation_action', mm_cfg.Radio, + (_('Hold'), _('Reject'), _('Discard')), 0, + _("""Action to take when anyone posts to the + list from a domain with a DMARC Reject Policy."""), + _("""""")), + + ('dmarc_moderation_notice', mm_cfg.Text, (10, WIDTH), 1, + _("""Text to include in any + rejection notice to + be sent to anyone who posts to this list from a domain + with DMARC Reject Policy.""")), + _('Non-member filters'), ('accept_these_nonmembers', mm_cfg.EmailListEx, (10, WIDTH), 1, diff --git a/Mailman/Handlers/Moderate.py b/Mailman/Handlers/Moderate.py index 199c97ac..81b554e4 100644 --- a/Mailman/Handlers/Moderate.py +++ b/Mailman/Handlers/Moderate.py @@ -56,6 +56,31 @@ def process(mlist, msg, msgdata): else: sender = None if sender: + if Utils.IsDmarcProhibited(sender): + # Note that for dmarc_moderation_action, 0==Hold, 1=Reject, + # 2==Discard + if mlist.dmarc_moderation_action == 0: + msgdata['sender'] = sender + Hold.hold_for_approval(mlist, msg, msgdata, + ModeratedMemberPost) + elif mlist.dmarc_moderation_action == 1: + # Reject + text = mlist.dmarc_moderation_notice + if text: + text = Utils.wrap(text) + else: + # Use the default RejectMessage notice string + text = None + raise Errors.RejectMessage, text + elif mlist.dmarc_moderation_action == 2: + raise Errors.DiscardMessage + else: + assert 0, 'bad dmarc_moderation_action' + + # sender's domain has a 'p=reject' _dmarc TXT record, + # we should NOT automatically reflect this email + return + # If the member's moderation flag is on, then perform the moderation # action. if mlist.getMemberOption(sender, mm_cfg.Moderate): diff --git a/Mailman/MailList.py b/Mailman/MailList.py index 2d653acb..a51f4ea6 100755 --- a/Mailman/MailList.py +++ b/Mailman/MailList.py @@ -388,6 +388,8 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, # 2==Discard self.member_moderation_action = 0 self.member_moderation_notice = '' + self.dmarc_moderation_action = 0 + self.dmarc_moderation_notice = '' self.accept_these_nonmembers = [] self.hold_these_nonmembers = [] self.reject_these_nonmembers = [] diff --git a/Mailman/Utils.py b/Mailman/Utils.py index 93e1fba1..a2cc0caa 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -71,6 +71,13 @@ except NameError: True = 1 False = 0 +try: + import dns.resolver + from dns.exception import DNSException + dns_resolver = True +except ImportError: + dns_resolver = False + EMPTYSTRING = '' UEMPTYSTRING = u'' NL = '\n' @@ -1057,3 +1064,35 @@ def suspiciousHTML(html): else: return False + +# This takes an email address, and returns True if DMARC policy is p=reject +def IsDmarcProhibited(email): + if not dns_resolver: + return False + + email = email.lower() + at_sign = email.find('@') + if at_sign < 1: + return False + dmarc_domain = '_dmarc.' + email[at_sign+1:] + + try: + resolver = dns.resolver.Resolver() + resolver.timeout = 1 + resolver.lifetime = 5 + txt_recs = resolver.query(dmarc_domain, dns.rdatatype.TXT) + except dns.resolver.NXDOMAIN: + return False + except DNSException, e: + syslog('error', 'DNSException: Unable to query DMARC policy for %s (%s). %s', + email, dmarc_domain, e.__class__) + return False + else: + for txt_rec in txt_recs.response.answer: + assert( txt_rec.rdtype == dns.rdatatype.TXT) + if re.search(r"[^s]p=reject", "".join(txt_rec.items[0].strings), re.IGNORECASE): + return True + + return False + + diff --git a/Mailman/versions.py b/Mailman/versions.py index 84943efc..02d266da 100755 --- a/Mailman/versions.py +++ b/Mailman/versions.py @@ -385,6 +385,8 @@ def NewVars(l): # the current GUI description model. So, 0==Hold, 1==Reject, 2==Discard add_only_if_missing('member_moderation_action', 0) add_only_if_missing('member_moderation_notice', '') + add_only_if_missing('dmarc_moderation_action', 0) + add_only_if_missing('dmarc_moderation_notice', '') add_only_if_missing('new_member_options', mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS) # Emergency moderation flag -- cgit v1.2.3 From 43e5fac2b77716e590b7b9d3e9a35f15946dd4d6 Mon Sep 17 00:00:00 2001 From: Phil Pennock Date: Mon, 18 Mar 2013 18:07:57 -0400 Subject: Handle CNAMEs when chasing DMARC TXT records. Handle TXT records missing tags, check all such records, etc. Use \b boundary anchors in regexp check. (Should only be one, but if there are multiple, check them all, reject if any of them say p=reject). --- Mailman/Utils.py | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) (limited to 'Mailman') diff --git a/Mailman/Utils.py b/Mailman/Utils.py index a2cc0caa..6c839a3c 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -35,6 +35,7 @@ import errno import base64 import random import urlparse +import collections import htmlentitydefs import email.Header import email.Iterators @@ -1081,18 +1082,56 @@ def IsDmarcProhibited(email): resolver.timeout = 1 resolver.lifetime = 5 txt_recs = resolver.query(dmarc_domain, dns.rdatatype.TXT) - except dns.resolver.NXDOMAIN: + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): return False except DNSException, e: syslog('error', 'DNSException: Unable to query DMARC policy for %s (%s). %s', email, dmarc_domain, e.__class__) return False else: +# people are already being dumb, don't trust them to provide honest DNS +# where the answer section only contains what was asked for, nor to include +# CNAMEs before the values they point to. + full_record = "" + results_by_name = collections.defaultdict(list) + cnames = {} + want_names = set([dmarc_domain + '.']) for txt_rec in txt_recs.response.answer: - assert( txt_rec.rdtype == dns.rdatatype.TXT) - if re.search(r"[^s]p=reject", "".join(txt_rec.items[0].strings), re.IGNORECASE): - return True - + if txt_rec.rdtype == dns.rdatatype.CNAME: + cnames[txt_rec.name.to_text()] = txt_rec.items[0].target.to_text() + if txt_rec.rdtype != dns.rdatatype.TXT: + continue + results_by_name[txt_rec.name.to_text()].append("".join(txt_rec.items[0].strings)) + expands = list(want_names) + seen = set(expands) + while expands: + item = expands.pop(0) + if item in cnames: + if cnames[item] in seen: + continue # cname loop + expands.append(cnames[item]) + seen.add(cnames[item]) + want_names.add(cnames[item]) + want_names.discard(item) + + if len(want_names) != 1: + syslog('error', 'multiple DMARC entries in results for %s, processing each to be strict', + dmarc_domain) + for name in want_names: + if name not in results_by_name: + continue + dmarcs = filter(lambda n: n.startswith('v=DMARC1;'), results_by_name[name]) + if len(dmarcs) == 0: + return False + if len(dmarcs) > 1: + syslog('error', 'RRset of TXT records for %s has %d v=DMARC1 entries; testing them all', + dmarc_domain, len(dmarc)) + for entry in dmarcs: + if re.search(r'\bp=reject\b', entry, re.IGNORECASE): + syslog('info', 'DMARC lookup for %s (%s) found p=reject in %s = %s', + email, dmarc_domain, name, entry) + return True + return False -- cgit v1.2.3 From a1bf240906a8774ae63f1c3299ebd1079c217cce Mon Sep 17 00:00:00 2001 From: Jim Popovitch Date: Sun, 20 Oct 2013 21:39:23 +0000 Subject: Incorporated some feedback from Mark S. --- Mailman/Handlers/Moderate.py | 16 +++++----------- Mailman/MailList.py | 2 +- Mailman/Utils.py | 2 +- 3 files changed, 7 insertions(+), 13 deletions(-) (limited to 'Mailman') diff --git a/Mailman/Handlers/Moderate.py b/Mailman/Handlers/Moderate.py index 81b554e4..9b5f0600 100644 --- a/Mailman/Handlers/Moderate.py +++ b/Mailman/Handlers/Moderate.py @@ -57,13 +57,13 @@ def process(mlist, msg, msgdata): sender = None if sender: if Utils.IsDmarcProhibited(sender): - # Note that for dmarc_moderation_action, 0==Hold, 1=Reject, - # 2==Discard - if mlist.dmarc_moderation_action == 0: + # Note that for dmarc_moderation_action, 0 = Accept, + # 1 = Hold, 2 = Reject, 3 = Discard + if mlist.dmarc_moderation_action == 1: msgdata['sender'] = sender Hold.hold_for_approval(mlist, msg, msgdata, ModeratedMemberPost) - elif mlist.dmarc_moderation_action == 1: + elif mlist.dmarc_moderation_action == 2: # Reject text = mlist.dmarc_moderation_notice if text: @@ -72,14 +72,8 @@ def process(mlist, msg, msgdata): # Use the default RejectMessage notice string text = None raise Errors.RejectMessage, text - elif mlist.dmarc_moderation_action == 2: + elif mlist.dmarc_moderation_action == 3: raise Errors.DiscardMessage - else: - assert 0, 'bad dmarc_moderation_action' - - # sender's domain has a 'p=reject' _dmarc TXT record, - # we should NOT automatically reflect this email - return # If the member's moderation flag is on, then perform the moderation # action. diff --git a/Mailman/MailList.py b/Mailman/MailList.py index a51f4ea6..4a3e92a8 100755 --- a/Mailman/MailList.py +++ b/Mailman/MailList.py @@ -388,7 +388,7 @@ class MailList(HTMLFormatter, Deliverer, ListAdmin, # 2==Discard self.member_moderation_action = 0 self.member_moderation_notice = '' - self.dmarc_moderation_action = 0 + self.dmarc_moderation_action = mm_cfg.DEFAULT_DMARC_MODERATION_ACTION self.dmarc_moderation_notice = '' self.accept_these_nonmembers = [] self.hold_these_nonmembers = [] diff --git a/Mailman/Utils.py b/Mailman/Utils.py index 6c839a3c..ec9174c7 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -1079,7 +1079,7 @@ def IsDmarcProhibited(email): try: resolver = dns.resolver.Resolver() - resolver.timeout = 1 + resolver.timeout = 3 resolver.lifetime = 5 txt_recs = resolver.query(dmarc_domain, dns.rdatatype.TXT) except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): -- cgit v1.2.3 From 715eeb4227e48eba5c1aff1a0be219dda4faa5db Mon Sep 17 00:00:00 2001 From: Jim Popovitch Date: Sun, 20 Oct 2013 21:50:21 +0000 Subject: Incorporated some feedback from Mark S. --- Mailman/versions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'Mailman') diff --git a/Mailman/versions.py b/Mailman/versions.py index 02d266da..7973e427 100755 --- a/Mailman/versions.py +++ b/Mailman/versions.py @@ -385,7 +385,8 @@ def NewVars(l): # the current GUI description model. So, 0==Hold, 1==Reject, 2==Discard add_only_if_missing('member_moderation_action', 0) add_only_if_missing('member_moderation_notice', '') - add_only_if_missing('dmarc_moderation_action', 0) + add_only_if_missing('dmarc_moderation_action', + mm_cfg.DEFAULT_DMARC_MODERATION_ACTION) add_only_if_missing('dmarc_moderation_notice', '') add_only_if_missing('new_member_options', mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS) -- cgit v1.2.3 From 80f14d7924d0a1b90873ecdc8388a10d31e440b3 Mon Sep 17 00:00:00 2001 From: Jim Popovitch Date: Sun, 20 Oct 2013 21:55:48 +0000 Subject: Incorporated feedback from Mark S. --- Mailman/Gui/Privacy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Mailman') diff --git a/Mailman/Gui/Privacy.py b/Mailman/Gui/Privacy.py index ae78b209..3e963b7a 100644 --- a/Mailman/Gui/Privacy.py +++ b/Mailman/Gui/Privacy.py @@ -236,7 +236,7 @@ class Privacy(GUIBase): be sent to moderated members who post to this list.""")), ('dmarc_moderation_action', mm_cfg.Radio, - (_('Hold'), _('Reject'), _('Discard')), 0, + (_('Accept'), _('Hold'), _('Reject'), _('Discard')), 0, _("""Action to take when anyone posts to the list from a domain with a DMARC Reject Policy."""), _("""
  • Hold -- this holds the message for approval -- cgit v1.2.3 From bc05ad4e81bd2ce9ec0f36e5112eadf607a49195 Mon Sep 17 00:00:00 2001 From: Jim Popovitch Date: Fri, 8 Nov 2013 00:56:42 +0000 Subject: Added suuport for p=quarantine based on feedback from Franck Martin. --- Mailman/Gui/Privacy.py | 4 ++-- Mailman/Utils.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) (limited to 'Mailman') diff --git a/Mailman/Gui/Privacy.py b/Mailman/Gui/Privacy.py index 3e963b7a..90560bff 100644 --- a/Mailman/Gui/Privacy.py +++ b/Mailman/Gui/Privacy.py @@ -238,7 +238,7 @@ class Privacy(GUIBase): ('dmarc_moderation_action', mm_cfg.Radio, (_('Accept'), _('Hold'), _('Reject'), _('Discard')), 0, _("""Action to take when anyone posts to the - list from a domain with a DMARC Reject Policy."""), + list from a domain with a DMARC Reject/Quarantine Policy."""), _("""
    • Hold -- this holds the message for approval by the list moderators. @@ -257,7 +257,7 @@ class Privacy(GUIBase): rejection notice to be sent to anyone who posts to this list from a domain - with DMARC Reject Policy.""")), + with DMARC Reject/Quarantine Policy.""")), _('Non-member filters'), diff --git a/Mailman/Utils.py b/Mailman/Utils.py index ec9174c7..37ae940b 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -1132,6 +1132,11 @@ def IsDmarcProhibited(email): email, dmarc_domain, name, entry) return True + if re.search(r'\bp=quarantine\b', entry, re.IGNORECASE): + syslog('info', 'DMARC lookup for %s (%s) found p=quarantine in %s = %s', + email, dmarc_domain, name, entry) + return True + return False -- cgit v1.2.3